做图像处理这一行,跟纹理打交道是躲不开的。不管是布匹质检、木材表面缺陷检测、医学影像分析,还是遥感图像分类,纹理特征都是最常用的底层视觉信息之一。而在各种纹理描述子里,LBP(Local Binary Pattern,局部二值模式)是我个人用得最多、性价比最高的一类——实现简单、计算快、对光照变化不敏感,最关键的是不依赖深度学习也能在中小型样本上拿到还不错的分类效果。
这篇笔记我整理了自己用 Python 从零实现 LBP 特征的全过程,从最原始的算子定义讲起,逐步补上圆形邻域、旋转不变、等价模式这些扩展版本,然后给出完整的代码实现和一套可以直接改来用的纹理分类 demo。适合刚入坑 CV、准备做传统图像特征提取,或者想在没 GPU 的环境下快速搭一个纹理识别小系统的同学参考。
1. 纹理特征为什么需要 LBP:从“看图片”到“读纹理”
1.1 什么是纹理,为什么问题会落在“特征”上
纹理这个词听起来很玄,其实你盯着一块木板、一块织物、一片草地看,那种由重复出现的局部模式拼成的视觉感受就是纹理。和人眼直接“看”不同,计算机拿到的只是一堆像素值,它需要把“这块区域是粗糙的还是细腻的、是横纹的还是竖纹的”翻译成一组数值,这种数值就是纹理特征。
很多新手容易犯一个错:以为纹理特征就是把灰度直方图画出来。灰度直方图只能告诉你整张图亮暗分布,却完全丢掉了空间位置信息——一张棋盘格和一张随机噪声图,灰度直方图可能几乎一样,但视觉上差了十万八千里。纹理特征要解决的,恰恰是“像素之间的空间关系怎么量化”这个核心问题。
1.2 主流纹理特征方案对比:LBP 凭什么实用
传统纹理特征方案不少,我实际对比过几类,各有各的脾气:
| 特征方法 | 核心思想 | 优点 | 明显短板 |
|---|---|---|---|
| GLCM 灰度共生矩阵 | 统计特定方向上灰度对出现的频率 | 方向信息丰富,物理含义直观 | 计算量大,参数多,特征维度容易膨胀 |
| Gabor 滤波器组 | 用不同尺度和方向的正弦波卷积图像 | 接近哺乳动物视觉皮层响应,尺度方向可控 | 滤波器参数难调,特征维度高 |
| LBP | 比较中心像素与邻域像素的大小关系并编码 | 计算极快,光照单调变化不敏感,无需训练 | 原始版本对噪声敏感,单尺度描述力有限 |
| 深度特征 | 用 CNN 自动学特征表示 | 泛化能力强,识别上限高 | 需要大量样本和 GPU,可解释性差 |
我为什么在传统方案里优先推荐 LBP?核心原因是它的“性价比”:一个 LBP 特征提取,在普通 CPU 上一张 512×512 的灰度图只有几毫秒到十几毫秒的耗时,而 GLCM 和 Gabor 要慢一个量级。深度学习虽然强,但如果你手头只有几百张纹理样本,深模型反而容易过拟合,此时 LBP 这样手工特征是再合适不过的起步方案。
1.3 LBP 的核心思想一句话讲明白
LBP 的思想用一句话概括就是:看一个像素和它周围几个邻居谁大谁小,把“谁大谁小”这个关系编码成一个二进制数。
这听起来太简单了?但正是这种简单带来了两个重要特性。第一,它只关心相对大小,所以整体光照变亮或变暗,只要单调变化,结果几乎不变——这对实际拍摄场景太有用了。第二,二进制编码天然就是一组紧凑的数值,可以直接做直方图统计,喂给分类器时维度可控。
2. LBP 计算原理:从原始算子到扩展版本
2.1 原始 LBP 算子:3×3 邻域的二进制编码
最原始的 LBP 是在 2002 年之前就出现的版本,操作很简单:取一个 3×3 的窗口,用中心像素作为阈值,周围 8 个像素如果大于等于中心值就记为 1,小于就记为 0,然后按固定顺序读成一个 8 位二进制数,再转成十进制。
举个例子,假设一个 3×3 窗口的灰度值是:
56 48 70 42 58 62 85 79 71中心值是 58。按顺时针从左上角开始比较:
- 56 < 58 → 0
- 48 < 58 → 0
- 70 ≥ 58 → 1
- 62 ≥ 58 → 1
- 71 ≥ 58 → 1
- 79 ≥ 58 → 1
- 85 ≥ 58 → 1
- 42 < 58 → 0
于是得到二进制串 00111110,十进制就是 62。这个 62 就是该中心像素的 LBP 编码值。
这样做一遍之后,整张图就变成了一个 LBP 编码图。每个像素值都不再是亮度,而是它周围微结构的编号。不同编号对应不同的局部模式,比如:全 0 或全 1 是平缓区域,有连续跳变的位置往往对应边缘或角点。这种编码本身就是一种对图像微小模式的量化。
2.2 圆形邻域:从 3×3 到任意半径和采样点数
3×3 窗口有个先天缺陷:半径固定为 1,只能用 8 个邻居,很多尺度信息抓不到。后来研究者把邻域扩展成半径为 R 的圆形,在圆周上均匀取 P 个采样点,这就是扩展 LBP。这里的 R 不一定是整数,采样点也不一定落在像素中心,所以需要用双线性插值去估计该位置的灰度值。
采样点坐标的计算公式是:
x_p = x + R * cos(2π * p / P) y_p = y - R * sin(2π * p / P)其中 p 从 0 到 P-1。注意这里用 sin 的负号是为了让角度按图像坐标系的 y 轴向下方向正确旋转,实际实现时前后一致就行。双线性插值就是在采样点周围的 4 个像素之间按距离加权取值,这个操作在图像缩放里很常见,代码实现也不复杂。
引入圆形邻域后,LBP 从一个固定的 3×3 算子变成了可控尺度的特征提取器:P 越大方向分辨率越高,R 越大感受野越大。这也让 LBP 有了“多尺度”的可能——你完全可以在同一个图像上提取 R=1、R=2、R=3 三种 LBP,再把特征拼起来用。
2.3 旋转不变性:让特征不随图像转身而变
同一块纹理,拍照时转了 90 度,LBP 编码会完全不同,因为二进制串的起始点固定了。解决办法叫旋转不变 LBP(Rotation Invariant LBP,简称 riLBP):把二进制串循环右移 P 次,取所有移位结果中最小的那个十进制值作为最终编码。
这个操作的直观理解是:不管纹理朝哪个方向,我始终从“看起来最特别的方向”开始读起,方向信息被归一化了。举个例子,二进制串 11110000 的循环移位中,最小值是 00001111(十进制 15),那么所有能通过旋转变成这个模式的编码都会归到同一个值。
代价也很明显:方向信息没了。如果任务本身很依赖方向,比如区分横纹和竖纹的布料,那旋转不变反而有害。所以使用的时候要自己权衡,这也是后面调参部分我会再强调的点。
2.4 等价模式:把 256 种模式压缩成 59 种
8 个邻域的原始 LBP 有 2 的 8 次方即 256 种编码,做直方图时维度偏高,而且很多编码在实际图像中几乎不出现。研究者统计发现,自然纹理中大部分 LBP 模式属于“等价模式”(Uniform Pattern),也就是二进制串中 0→1 或 1→0 的跳变次数不超过 2 的模式。
比如 00000000(跳变 0 次)、01110000(跳变 2 次)都是等价模式,而 01010101(跳变 7 次)就不是。等价模式把所有非等价模式统一归成一个桶。这样 P=8 时,等价模式有 58 种,加上 1 个非等价桶,总共 59 维,维度直接降到了原来的 1/4 左右,而且统计上等价模式往往占所有模式数的 90% 以上,信息损失很小。
2.5 分块直方图:把逐像素编码变成全局特征
逐像素的 LBP 图没法直接当特征用——两张纹理图错开几个像素,逐像素比较就全对不上了。正确的做法是对 LBP 编码图做统计:把图像分成若干小格(比如 4×4 个 block),每个小格单独统计直方图,再把所有小格的直方图拼接成一个大向量。
分块直方图的妙处在于:它既有直方图的统计稳定性,又保留了局部空间位置信息。左上角块和右下角块的纹理如果不同,最终特征里会体现在不同位置的分量上。块数越多空间信息越精细,但特征维度和对对齐的敏感性也越高,一般 4×4 到 8×8 是个常用区间。
3. Python 实现:从零手写再到调库
3.1 环境准备
我用的环境是 Python 3.9 以上版本,主要依赖这几个库:
pip install numpy opencv-python scikit-image scikit-learn matplotlibnumpy 负责数组运算,opencv 负责图像读写和灰度转换,scikit-image 里面有现成的 LBP 实现,scikit-learn 用来做分类。如果你只是学习原理,其实 numpy 和 matplotlib 就够用了。
3.2 基础版 LBP:先搞懂循环怎么写
从零手写的第一版我没做任何优化,就是最直白的双重循环。代码如下:
import numpy as np import cv2 def basic_lbp(gray_img): h, w = gray_img.shape lbp_img = np.zeros((h, w), dtype=np.uint8) # 从左上角开始按顺时针排列的邻域偏移 offsets = [(-1, -1), (-1, 0), (-1, 1), (0, 1), (1, 1), (1, 0), (1, -1), (0, -1)] for y in range(1, h - 1): for x in range(1, w - 1): center = gray_img[y, x] code = 0 for i, (dy, dx) in enumerate(offsets): neighbor = gray_img[y + dy, x + dx] if neighbor >= center: code |= (1 << i) lbp_img[y, x] = code return lbp_img这里有两个细节要注意。第一,code |= (1 << i)表示把第 i 位设为 1,通过移位和按位或运算逐位拼出二进制数,比字符串拼接高效得多。第二,边缘像素没有完整邻域,这个实现里我直接把边界留成 0,如果任务对边缘敏感,可以用 OpenCV 的copyMakeBorder先把图扩一圈,或者改用 reflect 模式填充。
3.3 圆形邻域与双线性插值实现
圆形邻域版的难点在插值。我先写一个针对单个点的插值函数:
def bilinear_interp(img, y, x): h, w = img.shape if y < 0 or y >= h or x < 0 or x >= w: return 0 y0 = int(np.floor(y)) x0 = int(np.floor(x)) y1 = min(y0 + 1, h - 1) x1 = min(x0 + 1, w - 1) wy = y - y0 wx = x - x0 top = img[y0, x0] * (1 - wx) + img[y0, x1] * wx bottom = img[y1, x0] * (1 - wx) + img[y1, x1] * wx return top * (1 - wy) + bottom * wy def circular_neighbor(img, y, x, r, p): vals = [] for i in range(p): theta = 2 * np.pi * i / p nx = x + r * np.cos(theta) ny = y - r * np.sin(theta) vals.append(bilinear_interp(img, ny, nx)) return np.array(vals)然后聚合出完整的圆形邻域 LBP:
def circular_lbp(img, r=1, p=8): h, w = img.shape lbp_img = np.zeros((h, w), dtype=np.uint8) for y in range(r, h - r): for x in range(r, w - r): center = img[y, x] code = 0 for i, val in enumerate(circular_neighbor(img, y, x, r, p)): if val >= center: code |= (1 << i) lbp_img[y, x] = code return lbp_img从实现可以看出来,圆形邻域版最大的成本就是插值计算。P 取 8、R 取 1 时,采样点基本落在整数像素上,插值退化成直接取值,所以速度还算快;一旦 R 取 1.5、2 这种非整数值,插值的开销会明显上涨。实际项目中我一般直接用 scikit-image 的实现,手写版本主要用来验证和理解原理。
3.4 旋转不变与等价模式的实现
旋转不变的核心是循环移位取最小值:
def rotate_right(bin_val, n_bits): return ((bin_val >> 1) | (bin_val << (n_bits - 1))) & ((1 << n_bits) - 1) def rotation_invariant_code(bin_val, n_bits=8): min_val = bin_val tmp = bin_val for _ in range(n_bits): tmp = rotate_right(tmp, n_bits) if tmp < min_val: min_val = tmp return min_val等价模式需要先建立一张查找表,把 256 种编码映射到 59 个桶里:
def build_uniform_table(p): table = np.zeros(1 << p, dtype=np.int32) label = 0 for i in range(1 << p): bits = [(i >> k) & 1 for k in range(p)] trans = sum(bits[k] != bits[(k + 1) % p] for k in range(p)) if trans <= 2: table[i] = label label += 1 nonuniform_label = label # 所有非等价模式共用这个桶 for i in range(1 << p): bits = [(i >> k) & 1 for k in range(p)] trans = sum(bits[k] != bits[(k + 1) % p] for k in range(p)) if trans > 2: table[i] = nonuniform_label return table, nonuniform_label + 1注意跳变次数的计算,是把二进制串首尾相接来统计的,也就是最后一个 bit 和第一个 bit 也要比较一次。这一点特别容易漏,漏了之后等价模式的统计会和标准结果对不上。
3.5 直接调用 scikit-image 的现成实现
如果你不想重复造轮子,scikit-image 已经封装好了所有扩展版本:
from skimage.feature import local_binary_pattern lbp_default = local_binary_pattern(img_gray, P=8, R=1, method='default') lbp_ror = local_binary_pattern(img_gray, P=8, R=1, method='ror') lbp_uniform = local_binary_pattern(img_gray, P=8, R=1, method='uniform') lbp_nri_uniform = local_binary_pattern(img_gray, P=8, R=1, method='nri_uniform')method参数有几种选择:
default:原始的旋转可变模式,取值 0~255ror:旋转不变但非等价,取值 0~35uniform:旋转不变加等价,取值 0~58,非等价模式统一为 59nri_uniform:不旋转但与等价,取值 0~58,非等价统一为 59var:用邻域方差替代二值编码,得到的不是 LBP 而是局部对比度
输出是浮点型数组,这一点后面做直方图时容易踩坑,我会在常见问题里专门说。
4. 实战:用 LBP 特征搭一个纹理分类器
4.1 数据准备
我这边用的是自己整理的一个 5 类纹理样本集,每类 200 张 128×128 灰度图,包括麻布纹、横条纹、竖条纹、木纹理和网格纹。数据组织成这样的目录结构:
texture_dataset/ ├── fabric/ ├── horizontal/ ├── vertical/ ├── wood/ └── grid/每类下面直接放图片即可,后面用路径批量读取。如果手头没数据,也可以把一张大纹理图切块切成几百张小图,效果类似。
4.2 特征提取封装
我习惯把特征提取封装成一个函数,输入是灰度图,输出是一维特征向量:
def extract_lbp_feature(img, p=8, r=1, method='uniform', block=(4, 4)): lbp = local_binary_pattern(img, P=p, R=r, method=method) n_bins = p * (p - 1) + 3 if method == 'uniform' else 1 << p feats = [] h, w = lbp.shape bh, bw = h // block[0], w // block[1] for i in range(block[0]): for j in range(block[1]): cell = lbp[i * bh:(i + 1) * bh, j * bw:(j + 1) * bw] hist, _ = np.histogram(cell, bins=n_bins, range=(0, n_bins)) hist = hist.astype(np.float32) hist /= (hist.sum() + 1e-6) feats.append(hist) return np.concatenate(feats)几个关键点说一下。第一,n_bins的算法要和方法对应,uniform 模式下是 59,default 模式下是 256。第二,np.histogram的range必须显式给成(0, n_bins),否则数组里的最大值接近 59 但类型是浮点,直方图分桶很可能出错。第三,直方图要做 L1 归一化,消除图像尺寸差异带来的影响。
4.3 分类器与整体流程
特征提取完后,训练分类器就非常简单了。我用 kNN 和线性 SVM 都试过,小样本场景下两者都能用,但 kNN 更省事:
import numpy as np from sklearn.model_selection import train_test_split from sklearn.neighbors import KNeighborsClassifier from sklearn.svm import SVC X = [] y = [] class_names = ['fabric', 'horizontal', 'vertical', 'wood', 'grid'] for label, name in enumerate(class_names): for img_path in sorted(glob.glob(f'texture_dataset/{name}/*.png')): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) feats = extract_lbp_feature(img) X.append(feats) y.append(label) X = np.array(X) y = np.array(y) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y) clf = KNeighborsClassifier(n_neighbors=5) clf.fit(X_train, y_train) print('kNN accuracy:', clf.score(X_test, y_test)) svm = SVC(kernel='linear', C=1.0) svm.fit(X_train, y_train) print('SVM accuracy:', svm.score(X_test, y_test))我在自己的这个 5 类样本集上,uniform 模式加 4×4 分块,kNN 准确率在 93% 左右,线性 SVM 能到 95% 上下。这个结果已经能说明 LBP 的实用性了——没经过任何调参,纯靠特征本身就能把五类纹理基本分开。
5. 调参与避坑心得
5.1 关键参数到底怎么选
LBP 需要重点调的是 P、R、method 和 block 四个参数。我给个自己常用的参考:
| 参数 | 常用区间 | 我的经验 |
|---|---|---|
| P | 8、16 | 样本小或图像小用 8,方向细节要求高用 16 |
| R | 1、2、3 | R=1 抓微观纹理,R=3 抓宏观结构,可多尺度拼接 |
| method | uniform / ror / default | 默认优先 uniform,维度低且稳;方向敏感任务用 nri_uniform |
| block | 4×4、8×8、1×1 | 图像对齐好用小 block,对齐差用大 block 或全图直方图 |
一个很容易忽略的原则:block 的尺寸和图像比有关,不是越多越好。128×128 的图用 16×16 block,每个小格只有 8×8 像素,直方图统计极不稳定,反而把性能拉低。我见过不少人一上来就追求精细分块,结果准确率暴跌。
5.2 我踩过的几个坑
踩坑一:local_binary_pattern的输出是浮点数组,直接拿去做np.uint8强转会在 59 这个边界处出问题。正确做法是直方图之前保留浮点,分桶时用浮点 range。踩坑二:等价模式表的跳变次数要首尾相接统计,很多网上代码只统计了相邻位、漏掉最后一位和第一位,导致模式映射错误。踩坑三:手写版本和 scikit-image 版本的方向定义可能不一样,同一个图一个顺时针一个逆时针,特征对不上。如果一边手写、一边调库,务必统一采样方向。
还有一个坑是直方图归一化。样本量不一致时,不归一化,分类结果会被图像面积大的样本主导。但如果后续分类器用的是决策树这类对尺度不敏感的模型,归一化倒无所谓,所以也别无条件照搬。
5.3 性能优化经验
纯 Python 双重循环实现一张 512×512 图要一两秒,根本没法上生产。想提速我试过比较有效的几个方向:
- 用 numpy 向量化替代像素循环,计算所有邻域的差值矩阵,耗时能降一个数量级以上
- 图像先缩放到 256×256 或更小,纹理特征对分辨率不像深度学习那么敏感
- 提取一次特征后存成文件缓存,训练和测试阶段直接读特征,避免反复跑
- 分块直方图可以用积分直方图加速,每个 block 的直方图通过四次查表得到,速度提升明显
如果只是学习或做原型,上面这些优化都不是必须的,但真到了几千张图的规模,还是要尽早考虑。
6. 写在最后:一点个人体会
LBP 是我在传统图像特征里使用率最高的工具,它教会我一件很重要的事:特征不一定越复杂越好。很多问题,尤其是有明确物理背景的纹理识别任务,简单算子加合理统计往往比盲目套用深度模型更稳、更快、更容易解释。
根据我个人的实际经验,LBP 最适合的场景是中小规模样本、硬件条件有限、需要快速验证想法的情况。如果样本量足够大且有 GPU,深度模型的上限会更高;但即便是在深度学习流程里,LBP 也常被用来做辅助特征或后验校验。比如我就经常把 LBP 特征和深度特征拼接起来,在不少任务上都能带来一两个百分点的稳定提升。
最后分享一个小技巧:调参时不要一次性把所有参数都改掉。我的习惯是先固定 P=8、R=1、method='uniform'、block=4×4,跑一个基线结果,然后每次只调一个维度,用测试集准确率画小表格对比。这样你能清楚知道每个参数到底贡献了多少,而不是黑箱式地碰运气。纹理特征这条路,走进去之后你会发现,很多看似简单的方法,背后的设计理由远比想象中讲究。