学习 Android (十七) 学习 OpenCV (二)
在上一章节,我们对什么是OpenCV、如何搭建Android OpenCV SDK、Mat、Imgproc类详解、OpenCV 中的亮度与对比度、颜色模型及转换、多通道分离与合并、图像二值化有了一定的学习和了解,但这对于 OpenCV 的内容来说还差个十万八千里,接下来继续跟作者一起学习 OpenCV 吧
9. 图像透视变换
9.1 什么是透视变换
想象你侧着拍一张放在桌上的卡片,照片里的卡片可能变形、不方正了。透视变换 就是帮你把这张卡片"拉正",变成像从正上方垂直拍下去的效果。这是一种投影变换 ,它通过一个 3x3 的变换矩阵 来计算原图与目标图像中点之间的关系。
在 Android OpenCV 中,透视变换主要依赖两个核心函数:Imgproc.getPerspectiveTransform
和 Imgproc.warpPerspective
。
9.2 如何实现透视变换
在 Android OpenCV 中实现图像透视变换通常包含以下步骤:
-
获取源点和目标点: 首先需要定义原始图像上我们希望变换的四边形区域的四个顶点(
srcPoints
),以及这些点你希望它们变换到目标图像上的位置(dstPoints
)。 -
计算变换矩阵: 使用
Imgproc.getPerspectiveTransform
函数,根据上述两组点计算出透视变换矩阵(Mat M
)。 -
应用变换: 使用
Imgproc.warpPerspective
函数,将上一步得到的变换矩阵应用到原始图像上,得到矫正后的图像。
9.3 关键函数的使用说明:
getPerspectiveTransform
(Mat src, Mat dst)
; (Mat src, Mat dst, int solveMethod)
计算透视变换矩阵:
-
src
: 原图像四边形顶点坐标(MatOfPoint2f
) -
dst
: 目标图像四边形顶点坐标(MatOfPoint2f
) -
solveMethod
: (可选)计算方法,默认Core.DECOMP_LU
warpPerspective
(Mat src, Mat dst, Mat M, Size dsize)
; (Mat src, Mat dst, Mat M, Size dsize, int flags, int borderMode, Scalar borderValue)
应用透视变换:
-
src
: 输入图像 -
dst
: 输出图像 -
M
: 3x3 变换矩阵 -
dsize
: 输出图像大小 -
flags
: (可选)插值方法(如Imgproc.INTER_LINEAR
) -
borderMode
: (可选)像素外推方法(如Core.BORDER_CONSTANT
) -
borderValue
: (可选)填充边界使用的值,默认为0(黑色)
9.4 示例
通过函数说明,我们可以发现其关键函数中的参数都需要原图像的顶点坐标,那么如何获取到原图像的四边形顶点坐标就成为了处理图像透视的关键过程。获取图像四边形的顶点坐标的方式有很多种,这里作者就进行,图像灰度化 -> 边缘检测(如Canny) -> 查找轮廓或检测直线 -> 找出最可能是四边形边框的轮廓或四条线 -> 求出四个角点的流程来实现
QuadrilateralDetector.java
java
public class QuadrilateralDetector {
/**
* 从图像中检测四边形并返回四个角点
* @param src 输入图像(彩色或灰度)
* @return 四边形的四个角点,如果没有检测到则返回null
*/
public static MatOfPoint2f detectQuadrilateral(Mat src) {
// 1. 转换为灰度图像
Mat gray = new Mat();
if (src.channels() == 3) {
Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
} else {
gray = src.clone();
}
// 2. 应用高斯模糊减少噪声
Mat blurred = new Mat();
Imgproc.GaussianBlur(gray, blurred, new Size(5, 5), 0);
// 3. 边缘检测 - Canny算法
Mat edges = new Mat();
// 使用自适应阈值确定Canny参数
double otsuThresh = Imgproc.threshold(blurred, new Mat(), 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU);
double highThresh = otsuThresh;
double lowThresh = otsuThresh * 0.5;
Imgproc.Canny(blurred, edges, lowThresh, highThresh);
// 4. 形态学操作 - 闭合小间隙
Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(5, 5));
Imgproc.morphologyEx(edges, edges, Imgproc.MORPH_CLOSE, kernel);
// 5. 查找轮廓
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
Imgproc.findContours(edges, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);
// 6. 按面积排序轮廓(从大到小)
Collections.sort(contours, new Comparator<MatOfPoint>() {
@Override
public int compare(MatOfPoint o1, MatOfPoint o2) {
return Double.compare(Imgproc.contourArea(o2), Imgproc.contourArea(o1));
}
});
// 7. 遍历轮廓,寻找四边形
for (MatOfPoint contour : contours) {
// 计算轮廓周长
double perimeter = Imgproc.arcLength(new MatOfPoint2f(contour.toArray()), true);
// 多边形近似
MatOfPoint2f approx = new MatOfPoint2f();
Imgproc.approxPolyDP(new MatOfPoint2f(contour.toArray()), approx, 0.02 * perimeter, true);
// 如果是四边形(4个顶点)
// 在 detectQuadrilateral 方法中添加检查
if (approx.toArray().length == 4) {
// 检查轮廓面积是否足够大
double area = Imgproc.contourArea(approx);
if (area > 1000) {
// 检查点是否都不为null
Point[] points = approx.toArray();
boolean hasNull = false;
for (Point p : points) {
if (p == null) {
hasNull = true;
break;
}
}
if (!hasNull) {
// 对四个点进行排序:左上、右上、右下、左下
Point[] sortedPoints = sortPoints(points);
return new MatOfPoint2f(sortedPoints);
}
}
}
}
// 如果没有找到合适的四边形
return null;
}
/**
* 对四个点进行排序:左上、右上、右下、左下
* 使用更稳定的排序方法,基于点的坐标值
*/
private static Point[] sortPoints(Point[] points) {
if (points.length != 4) {
throw new IllegalArgumentException("必须恰好有4个点");
}
// 按x坐标对点进行排序
List<Point> pointsList = Arrays.asList(points);
Collections.sort(pointsList, new Comparator<Point>() {
@Override
public int compare(Point p1, Point p2) {
return Double.compare(p1.x, p2.x);
}
});
// 分离左边和右边的点
Point[] leftPoints = new Point[2];
Point[] rightPoints = new Point[2];
leftPoints[0] = pointsList.get(0);
leftPoints[1] = pointsList.get(1);
rightPoints[0] = pointsList.get(2);
rightPoints[1] = pointsList.get(3);
// 对左边点按y坐标排序
Arrays.sort(leftPoints, new Comparator<Point>() {
@Override
public int compare(Point p1, Point p2) {
return Double.compare(p1.y, p2.y);
}
});
// 对右边点按y坐标排序
Arrays.sort(rightPoints, new Comparator<Point>() {
@Override
public int compare(Point p1, Point p2) {
return Double.compare(p1.y, p2.y);
}
});
// 返回排序后的点:左上、右上、右下、左下
return new Point[] {
leftPoints[0], // 左上
rightPoints[0], // 右上
rightPoints[1], // 右下
leftPoints[1] // 左下
};
}
/**
* 应用透视变换,将检测到的四边形转换为矩形
* @param src 原始图像
* @param corners 四边形的四个角点
* @param width 输出图像的宽度
* @param height 输出图像的高度
* @return 变换后的图像
*/
public static Mat applyPerspectiveTransform(Mat src, MatOfPoint2f corners, int width, int height) {
if (corners == null || corners.toArray().length != 4) {
return src.clone(); // 如果没有有效角点,返回原图副本
}
// 定义目标点(矩形)
Point[] dstPoints = new Point[4];
dstPoints[0] = new Point(0, 0); // 左上
dstPoints[1] = new Point(width, 0); // 右上
dstPoints[2] = new Point(width, height); // 右下
dstPoints[3] = new Point(0, height); // 左下
MatOfPoint2f dstMat = new MatOfPoint2f(dstPoints);
// 计算透视变换矩阵
Mat transform = Imgproc.getPerspectiveTransform(corners, dstMat);
// 应用透视变换
Mat result = new Mat();
Imgproc.warpPerspective(src, result, transform, new Size(width, height));
return result;
}
}
PerspectiveTransformationActivity.java
java
public class PerspectiveTransformationActivity extends AppCompatActivity {
private ActivityPerspectiveTransformationBinding mBinding;
static {
System.loadLibrary("opencv_java4");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityPerspectiveTransformationBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
new Thread(() -> {
try {
Mat resource = Utils.loadResource(this, R.drawable.opencv2);
runOnUiThread(() -> processImage(resource));
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
public void processImage(Mat inputImage) {
// 检测四边形
MatOfPoint2f corners = QuadrilateralDetector.detectQuadrilateral(inputImage);
if (corners != null) {
try {
Toast.makeText(this, "检测到四边形", Toast.LENGTH_SHORT).show();
// 绘制检测到的四边形(用于调试)
Mat debugImage = inputImage.clone();
Point[] points = corners.toArray();
for (int i = 0; i < 4; i++) {
Imgproc.line(debugImage, points[i], points[(i + 1) % 4], new Scalar(0, 255, 0), 3);
}
// 显示带标记的图像
showMat(mBinding.ivSource, debugImage);
// 应用透视变换
Mat transformed = QuadrilateralDetector.applyPerspectiveTransform(
inputImage, corners, 500, 500);
// 显示结果
showMat(mBinding.ivResult, transformed);
// 释放资源
debugImage.release();
transformed.release();
} finally {
corners.release();
}
} else {
// 没有检测到四边形
Toast.makeText(this, "未检测到四边形", Toast.LENGTH_SHORT).show();
// 显示原图
showMat(mBinding.ivSource, inputImage);
}
}
private void showMat(ImageView imageView, Mat source) {
Bitmap bitmap = Bitmap.createBitmap(source.cols(), source.rows(), Bitmap.Config.ARGB_8888);
Utils.matToBitmap(source, bitmap);
imageView.setImageBitmap(bitmap);
}
}
结果如下图所示

10. 图像仿射变换
10.1仿射变换是什么?
仿射变换是一种二维线性交换,它保持了图像的平直性 (直线变换后还是直线)和平行性 (平行线变换后仍然平行)。常见的放射变换包括:
-
平移
-
旋转
-
缩放
-
倾斜/剪切
-
上述变换的任意组合
与透视变换不同,仿射变换不会产生"近大远小"的透视效果,它始终保持平行线平行
数学原理:
仿射变化使用 2 * 3 变换矩阵:*
textile
M = [ a00 a01 a02 ]
[ a10 a11 a12 ]
变换公式为:
tex
x' = a00·x + a01·y + a02
y' = a10·x + a11·y + a12
用矩阵表示:
tex
[x'] = [a00 a01] [x] + [a02]
[y'] [a10 a11] [y] [a12]
10.2 关键函数的使用说明
Imgproc.getAffineTransform(MatOfPoint2f src, MatOfPoint2f dst)
通过三组点对应关系获取仿射变换矩阵
-
src:原图像中的三个点
-
dst:目标图像中对应的三个点
-
返回值:2×3 的仿射变换矩阵
Imgproc.getRotationMatrix2D(Point center, double angle, double scale)
通过旋转矩阵获取仿射变化矩阵
-
center:旋转中心点
-
angle:旋转角度(正值表示逆时针旋转)
-
scale:缩放比例
-
返回值:2×3 的旋转加缩放变换矩阵
Imgproc.warpAffine(Mat src, Mat dst, Mat M, Size dsize, int flags, int borderMode, Scalar borderValue)
应用仿射变换
-
src:输入图像
-
dst:输出图像
-
M:2×3 变换矩阵
-
dsize:输出图像大小
-
flags:插值方法(可选,默认 INTER_LINEAR)
-
borderMode:边界像素模式(可选,默认 BORDER_CONSTANT)
-
borderValue:边界填充值(可选,默认 0)
10.3 示例
AffineTransformsActivity.java
java
public class AffineTransformsActivity extends AppCompatActivity {
private ActivityAffineTransformsBinding mBinding;
static {
System.loadLibrary("opencv_java4");
}
private Mat originalMat;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityAffineTransformsBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
// 加载原始图像
originalMat = Utils.loadResource(this, R.drawable.lena);
// 显示原始图像
showMat(mBinding.ivOriginal, originalMat);
// 应用各种变换
applyTranslation();
applyRotation();
applyScaling();
applyThreePointTransform();
applyCombinedTransform();
} catch (Exception e) {
e.printStackTrace();
Toast.makeText(this, "加载图像失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
/**
* 平移交换
*/
private void applyTranslation() {
// 定义平移量
double tx = 100.0; // x 方向平移 100 像素
double ty = 50.0; // y 方向平移 50 像素
// 创建平移变换 2 * 3 矩阵
/**
* [ a00 a01 a02 ]
* [ a10 a11 a12 ]
* */
Mat translationMatrix = new Mat(2, 3, CvType.CV_64FC1);
translationMatrix.put(0, 0, 1.0); // a00
translationMatrix.put(0, 1, 0.0); // a01
translationMatrix.put(0, 2, tx); // a0x (X 平移量)
translationMatrix.put(1, 0, 0.0); // a10
translationMatrix.put(1, 1, 1.0); // a11
translationMatrix.put(1, 2, ty); // a12(Y平移量)
// 应用变换
Mat dst = new Mat();
Imgproc.warpAffine(originalMat, dst, translationMatrix, new Size(originalMat.cols() + tx, originalMat.rows() + ty));
// 显示结果
showMat(mBinding.ivTranslation, dst);
// 释放资源
dst.release();
translationMatrix.release();
}
/**
* 旋转变换
*/
private void applyRotation() {
// 计算旋转中心(图像中心)
Point center = new Point(originalMat.cols() / 2.0, originalMat.rows() / 2.0);
// 计算旋转后的图像大小
double radians = Math.toRadians(45.0);
double sin = Math.abs(Math.sin(radians));
double cos = Math.abs(Math.cos(radians));
// |原始宽度×cosθ| + |原始高度×sinθ|
int newWidth = (int) ((originalMat.cols() * cos) + originalMat.rows() * sin);
// |原始宽度×sinθ| + |原始高度×cosθ|
int newHeight = (int) ((originalMat.cols() * sin) + originalMat.rows() * cos);
// 获取旋转矩阵(旋转 45 度,不缩放)
// Mat rotationMatrix = Imgproc.getRotationMatrix2D(center, 45.0, 1.0);
// 调整旋转矩阵,使旋转后的图像完全可见
// rotationMatrix.put(0, 2, rotationMatrix.get(0, 2)[0] + (newHeight / 2.0 - center.x));
// rotationMatrix.put(1, 2, rotationMatrix.get(1, 2)[0] + (newHeight / 2.0 - center.y));
// 手动计算旋转矩阵
/**
* 在 OpenCV 的仿射变换中,使用简化的 2×3 矩阵:
* 在原图像的中心点上进行旋转操作
* [ cosθ -sinθ (1-cosθ)·cx + sinθ·cy ]
* [ sinθ cosθ (1-cosθ)·cy - sinθ·cx ]
* 为此我们需要手动的去修正偏移量
* */
Mat rotationMatrix = new Mat(2, 3, CvType.CV_64FC1);
rotationMatrix.put(0, 0, cos);
rotationMatrix.put(0, 1, -sin);
rotationMatrix.put(0, 2, ((1 - cos) * center.x + sin * center.y) + (newHeight / 2.0 - center.x));
rotationMatrix.put(1, 0, sin);
rotationMatrix.put(1, 1, cos);
rotationMatrix.put(1, 2, ((1 - cos) * center.y - sin * center.x) + (newHeight / 2.0 - center.y));
// 应用变换
Mat dst = new Mat();
Imgproc.warpAffine(originalMat, dst, rotationMatrix, new Size(newWidth, newHeight));
// 显示结果
showMat(mBinding.ivRotation, dst);
// 释放资源
dst.release();
rotationMatrix.release();
}
/**
* 缩放变换
*/
private void applyScaling() {
// 创建缩放矩阵
double scaleX = 1.5; // x方向放大1.5倍
double scaleY = 0.8; // y方向缩小为0.8倍
/**
* 在 OpenCV 的仿射变换中,使用简化的 2×3 矩阵:
* [ sx 0 0 ]
* [ 0 sy 0 ]
* */
Mat scaleMatrix = new Mat(2, 3, CvType.CV_64FC1);
scaleMatrix.put(0, 0, scaleX);
scaleMatrix.put(0, 1, 0.0);
scaleMatrix.put(0, 2, 0.0);
scaleMatrix.put(1, 0, 0.0);
scaleMatrix.put(1, 1, scaleY);
scaleMatrix.put(1, 2, 0.0);
// 计算新图像大小
Size newSize = new Size(originalMat.cols() * scaleX, originalMat.rows() * scaleY);
// 应用变换
Mat dst = new Mat();
Imgproc.warpAffine(originalMat, dst, scaleMatrix, newSize);
// 显示结果
showMat(mBinding.ivScaling, dst);
// 释放资源
dst.release();
scaleMatrix.release();
}
/**
* 三点变换
*/
private void applyThreePointTransform() {
// 定义原图像中的三个点 取原图像左上、右上、左下三个点作为源点。
Point srcPoint1 = new Point(0, 0); // 左上角
Point srcPoint2 = new Point(originalMat.cols() - 1, 0); // 右上角
Point srcPoint3 = new Point(0, originalMat.rows() - 1); // 左下角
// 定义目标图像中对应的三个点 把目标图像的三个点平移了 50 个像素,形成偏移效果。
Point dstPoint1 = new Point(50, 50); // 左上角点平移
Point dstPoint2 = new Point(originalMat.cols() + 50, 50); // 右上角点平移
Point dstPoint3 = new Point(50, originalMat.rows() + 50); // 左下角点平移
/* // 创建点集
MatOfPoint2f srcPoints = new MatOfPoint2f(srcPoint1, srcPoint2, srcPoint3);
MatOfPoint2f dstPoints = new MatOfPoint2f(dstPoint1, dstPoint2, dstPoint3);
// 获取变换矩阵
Mat transformMatrix = Imgproc.getAffineTransform(srcPoints, dstPoints);*/
// 调用自定义方法来计算矩阵
Mat transformMatrix = calculateAffineTransformManual(new Point[]{srcPoint1, srcPoint2, srcPoint3}, new Point[]{dstPoint1, dstPoint2, dstPoint3});
;
// 应用变换 用计算出来的 2x3 仿射矩阵进行图像变换,得到结果图。
Mat dst = new Mat();
Imgproc.warpAffine(originalMat, dst, transformMatrix,
new Size(originalMat.cols() + 100, originalMat.rows() + 100));
// 显示结果
showMat(mBinding.ivThreePoint, dst);
// 释放资源
dst.release();
transformMatrix.release();
// srcPoints.release();
// dstPoints.release();
}
/**
* 我们知道,放射变换的数学模型为
* x′ = a00 * x + a01 * y + a02
* y' = a10 * x + a11 * y + a12
* 所以我们需要解出 6 个参数:a00, a01, a02, a10, a11, a12
*/
@SuppressLint("NewApi")
public static Mat calculateAffineTransformManual(Point[] srcPoints, Point[] dstPoints) {
if (srcPoints.length != 3 || dstPoints.length != 3) {
throw new IllegalArgumentException("需要恰好3个点");
}
// 构建矩阵 A 6x6 系数矩阵 和向量 B 6x1 的已知向量
double[][] A = new double[6][6];
double[] B = new double[6];
for (int i = 0; i < 3; i++) {
Point src = srcPoints[i];
Point dst = dstPoints[i];
// 方程1: x' = a00 * x + a01 * y + a02
A[2 * i][0] = src.x;
A[2 * i][1] = src.y;
A[2 * i][2] = 1;
A[2 * i][3] = 0;
A[2 * i][4] = 0;
A[2 * i][5] = 0;
B[2 * i] = dst.x;
// 方程2: y' = a10 * x + a11 * y + a12
A[2 * i + 1][0] = 0;
A[2 * i + 1][1] = 0;
A[2 * i + 1][2] = 0;
A[2 * i + 1][3] = src.x;
A[2 * i + 1][4] = src.y;
A[2 * i + 1][5] = 1;
B[2 * i + 1] = dst.y;
}
// 解线性方程组 AX = B
// 这里使用OpenCV的solve函数求解
Mat A_mat = new Mat(6, 6, CvType.CV_64FC1);
Mat B_mat = new Mat(6, 1, CvType.CV_64FC1);
Mat X_mat = new Mat(6, 1, CvType.CV_64FC1);
// 填充矩阵A和B
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 6; j++) {
A_mat.put(i, j, A[i][j]);
}
B_mat.put(i, 0, B[i]);
}
// 求解方程组
Core.solve(A_mat, B_mat, X_mat, Core.DECOMP_LU);
// 构建2x3变换矩阵
Mat transformMatrix = new Mat(2, 3, CvType.CV_64FC1);
transformMatrix.put(0, 0, X_mat.get(0, 0)[0]);
transformMatrix.put(0, 1, X_mat.get(1, 0)[0]);
transformMatrix.put(0, 2, X_mat.get(2, 0)[0]);
transformMatrix.put(1, 0, X_mat.get(3, 0)[0]);
transformMatrix.put(1, 1, X_mat.get(4, 0)[0]);
transformMatrix.put(1, 2, X_mat.get(5, 0)[0]);
A_mat.release();
B_mat.release();
X_mat.release();
return transformMatrix;
}
/**
* 组合变换
*/
private void applyCombinedTransform() {
// 计算旋转中心
Point center = new Point(originalMat.cols() / 2.0, originalMat.rows() / 2.0);
// 获取旋转和缩放矩阵(旋转30度,缩小为0.7倍)
Mat rotationMatrix = Imgproc.getRotationMatrix2D(center, 30.0, 0.7);
// 应用变换
Mat dst = new Mat();
Imgproc.warpAffine(originalMat, dst, rotationMatrix, originalMat.size());
// 显示结果
showMat(mBinding.ivCombined, dst);
// 释放资源
dst.release();
rotationMatrix.release();
}
// 显示Mat到ImageView
private void showMat(ImageView imageView, Mat mat) {
// 确保Mat是适合显示的格式
Mat displayMat = new Mat();
if (mat.channels() == 1) {
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_GRAY2BGR);
} else if (mat.channels() == 3) {
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_BGR2RGB);
} else {
mat.copyTo(displayMat);
}
// 转换为Bitmap并显示
android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap(
displayMat.cols(), displayMat.rows(), android.graphics.Bitmap.Config.ARGB_8888);
Utils.matToBitmap(displayMat, bitmap);
imageView.setImageBitmap(bitmap);
// 释放临时Mat
displayMat.release();
}
@Override
protected void onDestroy() {
super.onDestroy();
// 释放资源
if (originalMat != null) {
originalMat.release();
}
}
}
11. 极坐标变换
11.1 什么是极坐标
在我们熟悉的笛卡尔坐标系(Cartesian Coordinates)中,一个点的位置由 (x, y)
来表示。而在极坐标系(Polar Coordinates)中,一个点的位置由 (ρ, θ)
来表示。
-
ρ (rho): 表示从原点(极点)到该点的径向距离(Radius)。
-
θ (theta): 表示从正x轴到该点的角度(Angle)。
它们之间的转换关系如下:
-
从笛卡尔到极坐标 (Cartesian to Polar):
-
ρ = sqrt(x² + y²)
-
θ = atan2(y, x)
(atan2
函数能正确处理所有象限的角度)
-
-
从极坐标到笛卡尔 (Polar to Cartesian):
-
x = ρ * cos(θ)
-
y = ρ * sin(θ)
-
在图像处理中,极坐标变换 就是将图像从一个基于笛卡尔坐标的空间,重新映射到一个基于极坐标的空间。想象一下,你拿着一张方形的纸,把它卷成一个圆筒,或者反过来把一个圆环展开成一条矩形带,这个过程就类似于极坐标变换。
OpenCV 提供了两种主要的极坐标变换:
-
线性极坐标变换 (Linear Polar)
-
对数极坐标变换 (Log-Polar)
对数极坐标只是在径向距离 ρ
上使用了对数刻度 (log(ρ)
),这有助于将图像中心区域(细节丰富)放大,而压缩外围区域,在某些领域(如抗旋转分析)很有用。
11.2 关键函数的使用说明
在 Android OpenCV (Java) 中,核心的方法是 Imgproc.warpPolar
。
java
public static void warpPolar(Mat src, Mat dst, Size dsize, Point center, double maxRadius, int flags)
参数详解
-
Mat src
: 输入图像,可以是单通道或多通道(彩色图像)。 -
Mat dst
: 输出图像,其大小和类型与输入图像相同(除非在flags
中指定)。 -
Size dsize
: 输出图像的大小。这是一个非常关键的参数。-
对于极坐标变换 (
WARP_POLAR_LINEAR
或WARP_POLAR_LOG
):dsize
表示输出图像的尺寸。宽度 = θ 的维度(角度范围)
,高度 = ρ 的维度(径向范围)
。例如new Size(300, 200)
会生成一个 300px 宽(对应 0~360°),200px 高(对应 0~maxRadius
)的图像。 -
对于逆变换 (
WARP_INVERSE_MAP
):dsize
必须是原始笛卡尔图像的尺寸,这样才能正确还原。
-
-
Point center
: 极坐标变换的原点(极点) 。通常是你感兴趣区域的中心点,例如new Point(src.cols()/2, src.rows()/2)
。 -
double maxRadius
: 需要变换的最大径向距离 。只有距离中心点小于maxRadius
的像素点会被映射到输出图像中。- 通常可以取
Math.min(center.x, center.y)
来确保变换区域是一个内切圆,或者根据你的需求指定。
- 通常可以取
-
int flags
: 变换的标识符,是以下三者的组合:-
变换类型 (必选其一):
-
Imgproc.WARP_POLAR_LINEAR
: 线性极坐标变换。 -
Imgproc.WARP_POLAR_LOG
: 对数极坐标变换。
-
-
映射方向 (可选):
Imgproc.WARP_INVERSE_MAP
: 表示进行逆变换 ,即从极坐标空间变换回笛卡尔坐标空间。如果设置了这个标志,dsize
就应该是原始笛卡尔图像的尺寸。
-
插值方法 (可选,常与
WARP_FILL_OUTLIERS
一起使用):-
Imgproc.INTER_LINEAR
: 双线性插值(默认,速度快,质量不错)。 -
Imgproc.INTER_CUBIC
: 双三次插值(速度慢,质量更好)。 -
Imgproc.INTER_NEAREST
: 最近邻插值(速度快,容易产生锯齿)。 -
Imgproc.WARP_FILL_OUTLIERS
: 填充所有输出像素。如果某些像素在源图像中没有对应值,它们将被填充为0。
-
-
常见的 flags
组合示例:
-
Imgproc.WARP_POLAR_LINEAR | Imgproc.INTER_LINEAR
:线性极坐标变换 + 双线性插值。 -
Imgproc.WARP_POLAR_LOG | Imgproc.INTER_CUBIC
:对数极坐标变换 + 双三次插值。 -
Imgproc.WARP_POLAR_LINEAR | Imgproc.WARP_INVERSE_MAP | Imgproc.INTER_LINEAR
:线性极坐标的逆变换。
11.3 应用场景
-
环形物体矫正与分析
-
场景: 处理钟表表盘、CD光盘、轴承、瓶盖、瞳孔虹膜等圆形物体。
-
作用: 将环形的、弯曲的图案或文字变换成直线型的,极大方便了后续的OCR识别、尺寸测量、缺陷检测等操作。
-
-
旋转不变性分析与目标跟踪(对数极坐标)
-
场景 : 对数极坐标变换有一个重要性质:图像在原图中的旋转,在对数极坐标空间中表现为沿着θ轴的平移。
-
作用: 通过检测平移量,可以很容易地计算出目标的旋转角度。这对于需要克服旋转影响的目标识别和跟踪非常有用。
-
-
图像拼接与全景图创建
-
场景: 将多张广角或鱼眼镜头拍摄的图像拼接成全景图。
-
作用: 极坐标变换有时可以简化不同图像之间对应点的查找过程。
-
-
创造性图像效果
-
场景: App中的特效滤镜。
-
作用: 可以创造出"小行星"或"水晶球"一样的奇特视觉效果。这本质上是极坐标变换的一种应用。
-
11.4 示例
这里示例我就以环形物体矫正与分析,将环形文字转换成直线作为示例
PolarActivity.java
java
public class PolarActivity extends AppCompatActivity {
private ActivityPolarBinding mBinding;
static {
// 加载 OpenCV 库
System.loadLibrary("opencv_java4");
}
private Mat originalMat; // 用于保存原始图像
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityPolarBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
// 从资源文件加载一张图片 (res/drawable/polar.png)
originalMat = Utils.loadResource(this, R.drawable.polar);
// 显示原始图像
showMat(mBinding.ivOriginal, originalMat);
// 显示线性极坐标展开后的结果
Mat straightened = straightenCircularText(originalMat);
showMat(mBinding.ivStraighten, straightened);
// 将展开后的图像再反变换回环形
showMat(mBinding.ivRevert, revertToCircular(straightened, new Size(originalMat.cols(), originalMat.rows())));
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 将 Mat 显示到 ImageView 中
*/
private void showMat(ImageView imageView, Mat mat) {
Mat displayMat = new Mat();
// 根据通道数调整格式,确保可以显示
if (mat.channels() == 1) {
// 灰度图 -> BGR
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_GRAY2BGR);
} else if (mat.channels() == 3) {
// BGR -> RGB (Android 显示需要 RGB)
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_BGR2RGB);
} else {
mat.copyTo(displayMat);
}
// 转换为 Bitmap 并显示在 ImageView 上
android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap(
displayMat.cols(), displayMat.rows(), android.graphics.Bitmap.Config.ARGB_8888);
Utils.matToBitmap(displayMat, bitmap);
imageView.setImageBitmap(bitmap);
// 释放临时 Mat
displayMat.release();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (originalMat != null) {
originalMat.release(); // 释放内存
}
}
/**
* 将圆形/环形文字展开成直线形式(极坐标展开)
*/
public Mat straightenCircularText(Mat srcMat) {
// 1. 以图像中心作为极坐标变换的圆心
Point center = new Point(srcMat.cols() / 2.0, srcMat.rows() / 2.0);
// 2. 半径取图像中心到最短边的距离,保证不会超出图像边界
double maxRadius = Math.min(center.x, center.y);
// 3. 定义输出图像的尺寸
// 宽度 = 360 (角度范围)
// 高度 = 半径长度 (从圆心到边缘)
Size outputSize = new Size(360, maxRadius);
// 4. 创建目标 Mat
Mat dstMat = new Mat();
// 5. 调用 warpPolar 做线性极坐标变换
Imgproc.warpPolar(
srcMat, // 输入图像
dstMat, // 输出图像
outputSize, // 输出大小 (宽=角度, 高=半径)
center, // 极坐标原点
maxRadius, // 最大半径
Imgproc.WARP_POLAR_LINEAR | Imgproc.INTER_LINEAR
);
// 6. 旋转展开图,使文字方向更直观
Core.rotate(dstMat, dstMat, Core.ROTATE_90_CLOCKWISE);
return dstMat;
}
/**
* 将展开后的图像再变换回环形
*/
public Mat revertToCircular(Mat polarMat, Size originalSize) {
Mat originalMat = new Mat();
// 计算原始圆心和最大半径
Point center = new Point(originalSize.width / 2.0, originalSize.height / 2.0);
double maxRadius = Math.min(center.x, center.y);
// 先把之前旋转过的图像转回来
Core.rotate(polarMat, polarMat, Core.ROTATE_90_COUNTERCLOCKWISE);
// 调用 warpPolar 做逆变换(WARP_INVERSE_MAP)
Imgproc.warpPolar(
polarMat,
originalMat,
originalSize, // 输出尺寸必须是原始图像大小
center,
maxRadius,
Imgproc.WARP_POLAR_LINEAR | Imgproc.WARP_INVERSE_MAP | Imgproc.INTER_LINEAR
);
return originalMat;
}
}

12. 图像金字塔
12.1 什么是图像金字塔
想象一下古埃及的金字塔,从底层到顶层,尺寸越来越小。图像金字塔也是类似的概念,它是一系列以金字塔形状排列的图像集合,分辨率从底层到顶层逐渐降低。
其核心思想是:对图像进行重复的平滑(模糊)和降采样(缩小),从而生成多个不同尺度的图像。
OpenCV 中主要有两种类型的图像金字塔:
-
高斯金字塔 (Gaussian Pyramid)
-
拉普拉斯金字塔 (Laplacian Pyramid)
12.1.1 高斯金字塔 (Gaussian Pyramid) - 用于降维和缩放
原理:
高斯金字塔是通过不断应用"高斯模糊"和"降采样"来构建的。
-
第 0 层 (Level 0, G₀): 原始图像,是金字塔的基座。
-
第 1 层 (Level 1, G₁): 对 G₀ 进行高斯模糊,然后删除所有的偶数列和偶数行(即尺寸缩小为原来的 1/4,长宽各一半)。
-
第 2 层 (Level 2, G₂): 对 G₁ 进行同样的操作,得到更小的图像。
-
...: 以此类推,直到达到所需的层数。
这个过程称为 "下采样" (PyrDown)。
反过来,我们也可以对上层的小图像进行上采样 (PyrUp) 和模糊来预测或重建下层的图像,但这会丢失细节,无法完全还原。
12.1.2 拉普拉斯金字塔 (Laplacian Pyramid) - 用于重建和融合
原理:
拉普拉斯金字塔可以看作是高斯金字塔的补充,它存储的是图像的细节信息,即"残差"。
每一层拉普拉斯图像是由同一层的高斯图像和上一层高斯图像进行上采样后的"预测图像"之差计算得到的。
-
计算过程:
-
对高斯金字塔的第 i 层 (Gᵢ) 进行上采样(PyrUp)和模糊,得到一个和 Gᵢ₋₁ 尺寸一样的图像,我们称之为
PyrUp(Gᵢ)
。 -
然后用原始的高斯图像 Gᵢ₋₁ 减去这个预测图像:Lᵢ₋₁ = Gᵢ₋₁ - PyrUp(Gᵢ)
-
Lᵢ₋₁ 就是拉普拉斯金字塔的一层,它包含了 Gᵢ₋₁ 图像中
PyrUp(Gᵢ)
所无法预测的细节(如边缘、纹理)。
-
拉普拉斯金字塔的顶端就是高斯金字塔的顶端(因为没有更上一层可以减了)。一个关键特性是: 通过拉普拉斯金字塔和高斯金字塔的顶层,我们可以完美地逐层向上重建出原始图像。这使得它在图像融合(如苹果和橙子的融合)中非常有用。
12.2 关键函数的使用说明
在 Android OpenCV (Java) 中,构建金字塔的核心方法在 Imgproc
类中。
12.2.1 高斯金字塔的核心方法
a. pyrDown
- 下采样(构建金字塔的上一层)
java
public static void pyrDown(Mat src, Mat dst, Size dstsize)
-
src
: 输入图像(当前层)。 -
dst
: 输出图像(上一层),尺寸会自动计算为(src.cols()+1)/2, (src.rows()+1)/2)
。 -
dstsize
: 可选参数 。可以指定输出图像的大小。但必须满足|dstsize.width * 2 - src.cols| ≤ 2
和|dstsize.height * 2 - src.rows| ≤ 2
。通常传new Size()
使用默认大小即可。
b. pyrUp
- 上采样(用当前层预测下一层)
java
public static void pyrUp(Mat src, Mat dst, Size dstsize)
-
src
: 输入图像(当前层)。 -
dst
: 输出图像(下一层),尺寸会自动计算为src.cols()*2, src.rows()*2
。 -
dstsize
: 可选参数。同上,通常使用默认大小。
c. buildPyramid
- 快速构建整个高斯金字塔
如果你想直接得到整个金字塔的集合,这个方法非常高效。
java
public static void buildPyramid(Mat src, List<Mat> dst, int maxlevel)
-
src
: 输入源图像(第 0 层)。 -
dst
: 一个List<Mat>
,用于存放所有金字塔层的图像。方法执行后,dst.get(0)
是原图,dst.get(1)
是第 1 层,以此类推。 -
maxlevel
: 金字塔的最大层数(不包括第 0 层)。maxlevel=0
时只构建原图。
12.2.2 拉普拉斯金字塔的计算
OpenCV 没有直接计算拉普拉斯金字塔的单一方法,需要组合使用上述方法。
java
// 假设我们已经有了高斯金字塔的两层:G_i 和 G_i_plus_1 (G_i的上一层)
Mat G_i = ...; // 尺寸较大的一层
Mat G_i_plus_1 = ...; // 尺寸较小的一层
Mat pyrUpTemp = new Mat(); // 用于存储上采样后的图像
Mat laplacianLayer = new Mat(); // 存储拉普拉斯层
// 1. 将小图上采样
Imgproc.pyrUp(G_i_plus_1, pyrUpTemp, new Size(G_i.cols(), G_i.rows()));
// 2. 相减得到细节
Core.subtract(G_i, pyrUpTemp, laplacianLayer);
12.3 应用场景
-
图像融合 (Image Blending)
-
场景: 如上例所示,需要将多张图像无缝拼接在一起时。
-
原理: 在拉普拉斯金字塔的不同尺度上进行融合,可以避免在单一尺度上融合产生的明显接缝。
-
-
图像缩放 (Image Scaling)
-
场景: 需要快速生成图像的不同分辨率版本。
-
原理 : 使用
pyrDown
和pyrUp
可以方便地进行图像的放大和缩小。虽然效率高,但质量不如更复杂的插值算法(如INTER_LANCZOS4
)。
-
-
"图像金字塔"特征检测 (如 SIFT, SURF)
-
场景: 提取对尺度和旋转不变的特征点。
-
原理: 为了检测不同尺度下的特征点(如远处的小目标和近处的大目标),算法会在高斯金字塔的不同层上计算图像的梯度,从而找到稳定的关键点。
-
-
图像分割与对象检测
-
场景: 滑动窗口检测(如人脸检测、行人检测)。
-
原理: 使用图像金字塔来缩放图像本身,同时使用固定大小的检测窗口,可以检测出不同大小的目标。模型不需要处理各种尺寸的输入,只需在金字塔的不同层上跑同一个检测器即可。
-
-
图像压缩
-
场景: 需要多分辨率表示的场合。
-
原理: 拉普拉斯金字塔的层之间相关性低,并且顶层是高度压缩的低分辨率图像,可以用于高效的编码和传输。
-
12.4 示例
这里我们就以图像融合,融合苹果和橙子作为示例
PyramidActvity.java
java
public class PyramidActivity extends AppCompatActivity {
private ActivityPyramidBinding mBinding;
static {
// 加载 OpenCV 库
System.loadLibrary("opencv_java4");
}
private Mat originalAppleMat; // 用于保存原始图像
private Mat originalOrangeMat; // 用于保存原始图像
private Mat blendedMat; // 融合后的图片
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityPyramidBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
originalAppleMat = Utils.loadResource(this, R.drawable.apple);
originalOrangeMat = Utils.loadResource(this, R.drawable.orange);
showMat(mBinding.ivOriginalApple, originalAppleMat);
showMat(mBinding.ivOriginalOrange, originalOrangeMat);
blendedMat = blendImagesWithoutBuildPyramid(originalAppleMat, originalOrangeMat);
showMat(mBinding.ivBlend, blendedMat);
} catch (Exception e) {
e.printStackTrace();
}
}
public Mat blendImagesWithoutBuildPyramid(Mat apple, Mat orange) {
// 确保图像尺寸相同
if (apple.size().width != orange.size().width || apple.size().height != orange.size().height) {
Imgproc.resize(orange, orange, apple.size());
}
// 定义金字塔层数
int levels = 6;
// 创建存储金字塔的列表
List<Mat> appleGaussianPyr = new ArrayList<>();
List<Mat> orangeGaussianPyr = new ArrayList<>();
List<Mat> appleLaplacianPyr = new ArrayList<>();
List<Mat> orangeLaplacianPyr = new ArrayList<>();
// 第0层是原始图像
appleGaussianPyr.add(apple.clone());
orangeGaussianPyr.add(orange.clone());
// 1. 手动构建高斯金字塔
for (int i = 1; i < levels; i++) {
Mat appleDown = new Mat();
Mat orangeDown = new Mat();
// 使用pyrDown构建下一层
Imgproc.pyrDown(appleGaussianPyr.get(i-1), appleDown);
Imgproc.pyrDown(orangeGaussianPyr.get(i-1), orangeDown);
appleGaussianPyr.add(appleDown);
orangeGaussianPyr.add(orangeDown);
}
// 2. 构建拉普拉斯金字塔
// 从最顶层开始,顶层拉普拉斯层就是顶层高斯层
appleLaplacianPyr.add(appleGaussianPyr.get(levels-1));
orangeLaplacianPyr.add(orangeGaussianPyr.get(levels-1));
// 从顶层向下构建拉普拉斯金字塔
for (int i = levels-1; i > 0; i--) {
Mat appleUp = new Mat();
Mat orangeUp = new Mat();
// 将上一层高斯图像上采样
Imgproc.pyrUp(appleGaussianPyr.get(i), appleUp, appleGaussianPyr.get(i-1).size());
Imgproc.pyrUp(orangeGaussianPyr.get(i), orangeUp, orangeGaussianPyr.get(i-1).size());
// 计算拉普拉斯层:当前高斯层 - 上采样后的上一层
Mat appleLap = new Mat();
Mat orangeLap = new Mat();
Core.subtract(appleGaussianPyr.get(i-1), appleUp, appleLap);
Core.subtract(orangeGaussianPyr.get(i-1), orangeUp, orangeLap);
appleLaplacianPyr.add(0, appleLap); // 添加到列表开头
orangeLaplacianPyr.add(0, orangeLap);
}
// 3. 融合每一层拉普拉斯图像
List<Mat> blendedLaplacianPyr = new ArrayList<>();
for (int i = 0; i < levels; i++) {
Mat appleLap = appleLaplacianPyr.get(i);
Mat orangeLap = orangeLaplacianPyr.get(i);
// 创建掩码 - 左半部分为苹果,右半部分为橙子
Mat mask = Mat.zeros(appleLap.size(), CvType.CV_8UC1);
int midX = appleLap.cols() / 2;
// 绘制白色矩形作为苹果区域
Imgproc.rectangle(mask, new Point(0, 0), new Point(midX, appleLap.rows()), new Scalar(255), -1);
// 创建融合层
Mat blendedLayer = new Mat(appleLap.size(), appleLap.type());
// 复制苹果部分
appleLap.copyTo(blendedLayer, mask);
// 反转掩码
Mat invertedMask = new Mat();
Core.bitwise_not(mask, invertedMask);
// 复制橙子部分
orangeLap.copyTo(blendedLayer, invertedMask);
blendedLaplacianPyr.add(blendedLayer);
}
// 4. 从融合后的拉普拉斯金字塔重建图像
Mat currentBlended = blendedLaplacianPyr.get(levels-1); // 从最顶层开始
for (int i = levels-2; i >= 0; i--) {
Mat upsampled = new Mat();
// 上采样当前层
Imgproc.pyrUp(currentBlended, upsampled, blendedLaplacianPyr.get(i).size());
// 加上下一层的拉普拉斯细节
Core.add(upsampled, blendedLaplacianPyr.get(i), currentBlended);
}
return currentBlended;
}
/**
* 将 Mat 显示到 ImageView 中
*/
private void showMat(ImageView imageView, Mat mat) {
Mat displayMat = new Mat();
// 根据通道数调整格式,确保可以显示
if (mat.channels() == 1) {
// 灰度图 -> BGR
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_GRAY2BGR);
} else if (mat.channels() == 3) {
// BGR -> RGB (Android 显示需要 RGB)
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_BGR2RGB);
} else {
mat.copyTo(displayMat);
}
// 转换为 Bitmap 并显示在 ImageView 上
android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap(
displayMat.cols(), displayMat.rows(), android.graphics.Bitmap.Config.ARGB_8888);
Utils.matToBitmap(displayMat, bitmap);
imageView.setImageBitmap(bitmap);
// 释放临时 Mat
displayMat.release();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (originalAppleMat != null) {
originalAppleMat.release(); // 释放内存
}
if (originalOrangeMat != null) {
originalOrangeMat.release(); // 释放内存
}
if (blendedMat != null) {
blendedMat.release(); // 释放内存
}
}
}

13. 图像直方图
13.1 什么是图像直方图
图像直方图是一个统计学上的概念,它通过图形化表示来展示图像中像素强度分布 的情况。简单来说,它是一张图表,其 X 轴代表像素值的范围(例如 0 到 255),Y 轴代表具有该像素值的像素数量 或出现频率。
-
对于灰度图像: 只有一个通道,直方图显示的是所有像素从黑(0)到白(255)的分布情况。
-
对于彩色图像(如BGR): 通常有三个通道(Blue, Green, Red)。可以为每个通道分别计算直方图,从而了解不同颜色分量的分布。
核心概念:
-
Bins(条柱): 直方图的 X 轴通常不会为每一个可能的像素值(如0,1,2,...,255)都设一个柱子,而是将像素值范围划分为一系列连续的区间,这些区间就称为 "Bins"。例如,我们可以将 0-255 的范围划分为 16 个 bins(每个 bin 覆盖 16 个像素值),或者 256 个 bins(每个 bin 覆盖 1 个像素值)。Bin 的数量是直方图的一个重要参数。
-
Range(范围) : 要统计的像素值范围,通常是
[0, 256)
(即 0 到 255)。注意上限是 256 是因为范围定义通常是左闭右开[start, end)
。
13.2 关键函数的使用说明
在 Android OpenCV (Java) 中,计算直方图的核心方法是 Imgproc.calcHist()
。
java
public static void calcHist(List<Mat> images, MatOfInt channels, Mat mask, Mat hist, MatOfInt histSize, MatOfFloat ranges, boolean accumulate)
参数说明
-
List<Mat> images
: 输入图像的列表 。这是一个List
,意味着你可以同时计算多张图像的联合直方图,但最常见的是只放入一张图像Arrays.asList(srcMat)
。 -
MatOfInt channels
: 需要计算直方图的通道索引。-
对于灰度图像,它只有一个通道(通道 0),所以传入
new MatOfInt(0)
。 -
对于 BGR 彩色图像,如果你只想计算蓝色通道,传入
new MatOfInt(0)
;计算绿色传入new MatOfInt(1)
;计算红色传入new MatOfInt(2)
。如果要同时计算所有通道,传入new MatOfInt(0, 1, 2)
。
-
-
Mat mask
: 掩膜图像 。如果不想计算整个图像,而只想计算图像的某一部分,可以提供一个相同尺寸的掩膜(mask)。在掩膜中,只有白色区域(非零像素)对应的原图像素会被统计。如果计算整个图像,则传入new Mat()
(一个空矩阵)。 -
Mat hist
: 输出直方图 。这是一个多维的Mat
对象,用于存储计算出的直方图数据。它的维度由channels
参数的长度决定。 -
MatOfInt histSize
: 直方图的条柱(Bins)数量 。例如,如果你想将 0~255 分为 256 级,就传入new MatOfInt(256)
。如果你同时计算了多个通道,这个参数也需要对应多个值。例如,计算 BGR 三个通道,每个通道用 256 个 bins,则传入new MatOfInt(256, 256, 256)
。 -
MatOfFloat ranges
: 像素值的范围 。几乎总是new MatOfFloat(0, 256)
。如果你同时计算多个通道,并且每个通道的范围相同,则只需指定一次,OpenCV 会自动将其应用于所有通道。如果范围不同,则需要为每个通道指定。 -
boolean accumulate
: 累积标志 。如果设置为true
,则在多次调用calcHist
时,直方图不会被清零,而是累加到一起。通常设置为false
。
辅助方法
计算完直方图后,我们通常想把它可视化(绘制出来)或者进行比较。
-
归一化:
Core.normalize()
为了便于比较不同尺寸图像的直方图,我们通常将其归一化,使 Y 轴代表的是概率(频率)而不是绝对数量。
javaCore.normalize(sourceHist, normalizedHist, 0, 255, Core.NORM_MINMAX);
-
比较:
Imgproc.compareHist()
用于比较两个直方图的相似度,返回一个相似度分数。
javadouble similarity = Imgproc.compareHist(hist1, hist2, Imgproc.CV_COMP_CORREL);
-
比较方法 (第三个参数):
-
CV_COMP_CORREL
: 相关性,结果越接近 1 越相似。 -
CV_COMP_INTERSECT
: 直方图交集,值越大越相似。 -
CV_COMP_BHATTACHARYYA
: 巴氏距离,结果越接近 0 越相似。
-
-
13.3 应用场景
图像直方图的应用极其广泛,是许多高级计算机视觉任务的基础。
-
图像分析与人眼感知
-
场景: 评估图像质量。
-
作用: 通过观察直方图形状,可以快速判断图像的对比度、亮度分布。例如,直方图集中在左侧表示图像偏暗,集中在右侧表示偏亮,分布均匀则表示对比度良好。
-
-
图像增强:直方图均衡化 (Histogram Equalization)
-
场景: 增强低对比度图像的细节,如医学图像、卫星图像、背光照片。
-
作用 :
Imgproc.equalizeHist(src, dst)
通过重新分布像素强度值,使直方图尽可能均匀分布,从而拉伸图像的对比度,揭示隐藏的细节。
-
-
相机自动曝光 (AE) 和白平衡 (AWB)
-
场景: 手机、数码相机等成像设备。
-
作用: 相机实时分析场景的直方图,确保像素值分布在一个合适的范围内,避免过曝(直方图右侧溢出)或欠曝(左侧溢出)。
-
-
图像分割与目标检测
-
场景: 从背景中分离出前景物体。
-
作用 : 如果前景和背景的颜色或亮度差异明显,它们的直方图会呈现出可区分的峰值。通过阈值化(Thresholding)或反向投影(Back Projection)可以分割出目标。
Imgproc.threshold()
和Imgproc.calcBackProject()
都依赖于直方图。
-
-
内容-Based图像检索 (CBIR)
-
场景: 谷歌图片搜索、相册图片去重。
-
作用 : 直方图是图像的一种全局特征描述符。通过比较两张图像直方图的相似度(如上例中的
compareHist
),可以快速找到内容或颜色分布相似的图像。虽然精度不高,但计算速度快,常作为检索的第一步。
-
-
颜色校正与匹配
-
场景: 电影调色、品牌视觉一致性。
-
作用: 分析参考图像的色彩直方图,然后调整目标图像的直方图,使其与参考图像的颜色风格相匹配。
-
13.4 示例
CalcHistActivity.java
java
public class CalcHistActivity extends AppCompatActivity {
private ActivityCalcHistBinding mBinding;
static {
// 加载 OpenCV 库
System.loadLibrary("opencv_java4");
}
private Mat originalMat;
private Mat grayMat;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityCalcHistBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
originalMat = Utils.loadResource(this, R.drawable.lena);
showMat(mBinding.ivOriginal, originalMat);
grayMat = new Mat();
Imgproc.cvtColor(originalMat, grayMat, Imgproc.COLOR_BGR2GRAY);
showMat(mBinding.ivOriginalGray, grayMat);
calculateAndDrawGrayHistogram(grayMat, mBinding.ivCalcHist);
applyHistogramEqualization();
} catch (Exception e) {
e.printStackTrace();
}
}
public void calculateAndDrawGrayHistogram(Mat grayImage, ImageView histogramView) {
// 1. 初始化参数
List<Mat> images = Arrays.asList(grayImage); // 图像列表
MatOfInt channels = new MatOfInt(0); // 通道索引 (灰度图是0)
Mat mask = new Mat(); // 无掩膜
Mat hist = new Mat(); // 输出的直方图Mat
MatOfInt histSize = new MatOfInt(256); // 分成256个bins
MatOfFloat ranges = new MatOfFloat(0, 256); // 像素值范围 [0, 256)
boolean accumulate = false; // 不累积
// 2. 计算直方图
Imgproc.calcHist(images, channels, mask, hist, histSize, ranges, accumulate);
// 3. 归一化直方图 (为了绘制方便,缩放到一个统一的高度范围)
Core.normalize(hist, hist, 0, 400, Core.NORM_MINMAX); // 归一化到[0, 400]高度
// 4. 创建一个图像来绘制直方图
int histWidth = 512;
int histHeight = 400;
Mat histImage = Mat.zeros(histHeight, histWidth, CvType.CV_8UC3); // 黑色背景
Scalar whiteColor = new Scalar(255, 255, 255);
// 5. 绘制直方图曲线
Point[] points = new Point[(int) histSize.get(0, 0)[0]];
for (int i = 0; i < hist.rows(); i++) {
int binValue = (int) Math.round(hist.get(i, 0)[0]);
// 计算点的坐标
int x = i * (histWidth / hist.rows());
int y = histHeight - binValue; // Y坐标从下往上画
points[i] = new Point(x, y);
}
// 6. 用折线连接所有点
for (int i = 1; i < points.length; i++) {
Imgproc.line(histImage, points[i - 1], points[i], whiteColor, 2);
}
// 7. 显示到ImageView
Bitmap bmp = Bitmap.createBitmap(histImage.cols(), histImage.rows(), Bitmap.Config.ARGB_8888);
Utils.matToBitmap(histImage, bmp);
histogramView.setImageBitmap(bmp);
// 8. 释放资源
hist.release();
histImage.release();
}
private void applyHistogramEqualization() {
if (grayMat == null || grayMat.empty()) return;
Mat equalizedMat = new Mat();
Imgproc.equalizeHist(grayMat, equalizedMat);
// 显示均衡化后的图像
Bitmap equalizedBmp = Bitmap.createBitmap(equalizedMat.cols(), equalizedMat.rows(), Bitmap.Config.ARGB_8888);
Utils.matToBitmap(equalizedMat, equalizedBmp);
mBinding.ivEqualizeImage.setImageBitmap(equalizedBmp);
// 计算并显示均衡化后的直方图
calculateAndDrawGrayHistogram(equalizedMat, mBinding.ivEqualize);
// 释放资源
equalizedMat.release();
}
/**
* 将 Mat 显示到 ImageView 中
*/
private void showMat(ImageView imageView, Mat mat) {
Mat displayMat = new Mat();
// 根据通道数调整格式,确保可以显示
if (mat.channels() == 1) {
// 灰度图 -> BGR
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_GRAY2BGR);
} else if (mat.channels() == 3) {
// BGR -> RGB (Android 显示需要 RGB)
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_BGR2RGB);
} else {
mat.copyTo(displayMat);
}
// 转换为 Bitmap 并显示在 ImageView 上
android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap(
displayMat.cols(), displayMat.rows(), android.graphics.Bitmap.Config.ARGB_8888);
Utils.matToBitmap(displayMat, bitmap);
imageView.setImageBitmap(bitmap);
// 释放临时 Mat
displayMat.release();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (originalMat != null) {
originalMat.release();
}
}
}

14. 图像卷积
14.1 什么是图像卷积
直观理解
想象一下,你有一张照片,想要让它变得模糊一些,或者让边缘更加突出。卷积就是实现这些效果的数学工具。你可以把它理解为一个 "加权平均" 的过程。
有一个小窗口(称为卷积核 或滤波器)在图像上滑动。在每个位置,窗口覆盖的像素值会与窗口内对应的权重值相乘,然后将所有乘积结果加起来,得到一个新的像素值,并赋值给输出图像的中心像素。
数学表达
对于图像中的每个像素点 (x, y)
,其卷积操作可以表示为:
textile
dst(x, y) = sum_{i, j} (kernel(i, j) * src(x + i - anchor.x, y + j - anchor.y))
其中:
-
src
是输入图像。 -
dst
是输出图像。 -
kernel
是卷积核,一个小的矩阵,定义了滤波器的权重。 -
anchor
是卷积核的锚点,通常指向卷积核的中心。
卷积核(Kernel)是关键
卷积核是一个小矩阵,它决定了卷积操作的具体效果。以下是一些经典的卷积核:
-
identity kernel(恒等核) : 原图输出
textile[0, 0, 0] [0, 1, 0] [0, 0, 0]
-
Blur kernel(模糊核): 取周围像素的平均值,实现模糊效果。
textile[1, 1, 1] [1, 1, 1] * (1/9) [1, 1, 1]
-
Gaussian Blur kernel(高斯模糊核): 权重从中心向四周呈高斯分布衰减,模糊效果更自然。
textile[1, 2, 1] [2, 4, 2] * (1/16) [1, 2, 1]
-
Sharpen kernel(锐化核): 增强相邻像素之间的差异,使图像看起来更清晰。
textile[ 0, -1, 0] [-1, 5, -1] [ 0, -1, 0]
-
Edge Detection kernel(边缘检测核): 如 Sobel、Laplacian 核,用于突出图像中的边缘。
text// Sobel X (检测垂直边缘) [-1, 0, 1] [-2, 0, 2] [-1, 0, 1] // Laplacian [ 0, -1, 0] [-1, 4, -1] [ 0, -1, 0]
边界处理 (Border Handling)
当卷积核滑动到图像边缘时,它会"伸出"图像之外。OpenCV 提供了几种策略来处理这种情况:
-
BORDER_CONSTANT
: 用固定的颜色(如黑色)填充边界。iiiiii|abcdefgh|iiiiiii
-
BORDER_REPLICATE
: 复制最边缘的像素。aaaaaa|abcdefgh|hhhhhhh
-
BORDER_REFLECT
: 镜像反射边界像素。fedcba|abcdefgh|hgfedcb
-
BORDER_DEFAULT
(通常也是BORDER_REFLECT_101
) : 稍微不同的反射方式。gfedcb|abcdefgh|gfedcba
14.2 关键函数的使用说明
在 Android OpenCV 中,进行卷积操作的核心方法是 Imgproc.filter2D()
。对于特定的、常见的卷积操作(如高斯模糊),OpenCV 也提供了更便捷的封装方法。
java
public static void filter2D(Mat src, Mat dst, int ddepth, Mat kernel, Point anchor, double delta, int borderType)
参数详解:
-
Mat src
: 输入图像(源图像)。 -
Mat dst
: 输出图像,需要提前创建好,尺寸和类型通常与src
相同(除非ddepth
不同)。 -
int ddepth
: 期望的输出图像深度(例如,每个像素的位数)。常用值:-
-1
或CvType.CV_8U
: 输出图像深度与输入图像相同。 -
CvType.CV_16S
: 16位有符号整数,常用于 Sobel 算子等可能产生负值的操作。 -
CvType.CV_32F
: 32位浮点数,用于需要高精度的计算。
-
-
Mat kernel
: 卷积核 。一个浮点型(CvType.CV_32F
)的Mat
对象。这是卷积操作的灵魂。 -
Point anchor
: 卷积核的锚点 。它指示了核中与图像像素对齐的那个点。默认值(-1, -1)
或new Point(-1, -1)
表示锚点位于核的中心。 -
double delta
: 在将结果存储到输出图像之前,添加到结果中的一个可选值。可以用于调整亮度。 -
int borderType
: 上面提到的边界处理策略 。例如Core.BORDER_DEFAULT
。
14.3 作用和使用场景
-
图像降噪 (Noise Reduction)
-
场景: 去除照片中的随机噪点(如高ISO拍摄产生的噪点)。
-
技术 : 高斯模糊 、均值模糊 、中值模糊(特别擅长去除"椒盐噪声")。
-
-
图像锐化 (Image Sharpening)
-
场景: 增强图像的细节和纹理,使模糊的照片看起来更清晰。
-
技术 : 使用特定的锐化卷积核。
-
-
边缘检测 (Edge Detection)
-
场景: 计算机视觉、物体识别、图像分割的第一步。用于找出图像中物体的轮廓。
-
技术 : Sobel 、Scharr 、Laplacian 、Canny(Canny算法内部也使用了Sobel算子)等算子。
-
-
风格化与特效 (Stylization & Effects)
-
场景: 手机App中的滤镜(如浮雕效果、油画效果、素描效果)。
-
技术: 设计特定的卷积核来产生艺术效果。
-
-
计算机视觉预处理 (CV Preprocessing)
-
场景: 在运行复杂的计算机视觉算法(如特征点检测、模板匹配、OCR)之前,对图像进行预处理,以提升后续算法的准确性和鲁棒性。
-
技术: 常用高斯模糊来平滑图像,减少不必要的细节和噪声干扰。
-
14.4 示例
在看示例前,我们要知道,卷积核是一个小矩阵(常见是 3 * 3、5 * 5),用来对图像做 局部加权运算
以一个常见的 3×3 核为例:

-
每个元素 ki,j 对应邻域像素的权重。
-
中心位置 k0,0 对应当前像素本身。
-
上下左右、斜对角位置分别控制相邻像素的影响。
Filter2DActivity.java
java
public class Filter2DActivity extends AppCompatActivity {
private ActivityFilter2DactivityBinding mBinding;
static {
// 加载 OpenCV 库(JNI 层的函数依赖)
System.loadLibrary("opencv_java4");
}
// 当前卷积核的数值(默认 3x3,初始等同 FILTER_DEFAULT)
private List<Float> values = new ArrayList<>();
// RecyclerView 的适配器,用于展示和修改卷积核矩阵
private Filter2DAdapter filter2DAdapter;
public static final Float[] FILTER_DEFAULT = new Float[]{
0.0625F, 0.125F, 0.0625F,
0.125F, 0.25F, 0.125F,
0.0625F, 0.125F, 0.0625F
};
/**
* 均值模糊:
* 所有像素等权重平均 → 模糊效果。
*/
public static final Float[] FILTER_BLUR = new Float[]{
0.0625F, 0.125F, 0.0625F,
0.125F, 0.25F, 0.125F,
0.0625F, 0.125F, 0.0625F
};
/**
* 浮雕效果:
* 对角方向上突出/凹陷的边缘,产生浮雕感。
*/
public static final Float[] FILTER_EMBOSS = new Float[]{
-2F, -1F, 0F,
-1F, 1F, 1F,
0F, 1F, 2F
};
/**
* 单位卷积核(不改变图像)。
*/
public static final Float[] FILTER_IDENTITY = new Float[]{
0F, 0F, 0F,
0F, 1F, 0F,
0F, 0F, 0F
};
/**
* 边缘检测(拉普拉斯算子):
* 中心像素 - 周围像素 → 保留强对比边缘。
* 卷积核如下:
* [ -1 -1 -1
* -1 8 -1
* -1 -1 -1 ]
*/
public static final Float[] FILTER_OUTLINE = new Float[]{
-1F, -1F, -1F,
-1F, 8F, -1F,
-1F, -1F, -1F
};
/**
* 锐化:
* 中心像素加大权重,周围减小权重 → 边缘更加清晰。
* 卷积核如下:
* [ 0 -1 0
* -1 5 -1
* 0 -1 0 ]
*/
public static final Float[] FILTER_SHARPEN = new Float[]{
0F, -1F, 0F,
-1F, 5F, -1F,
0F, -1F, 0F
};
/**
* Sobel 算子(左边缘检测)。
*/
public static final Float[] FILTER_LEFT_SOBEL = new Float[]{
1F, 0F, -1F,
2F, 0F, -2F,
1F, 0F, -1F
};
/**
* Sobel 算子(右边缘检测)。
*/
public static final Float[] FILTER_RIGHT_SOBEL = new Float[]{
-1F, 0F, 1F,
-2F, 0F, 2F,
-1F, 0F, 1F
};
/**
* Sobel 算子(上边缘检测)。
*/
public static final Float[] FILTER_TOP_SOBEL = new Float[]{
1F, 2F, 1F,
0F, 0F, 0F,
-1F, -2F, -1F
};
/**
* Sobel 算子(下边缘检测)。
*/
public static final Float[] FILTER_BOTTOM_SOBEL = new Float[]{
-1F, -2F, -1F,
0F, 0F, 0F,
1F, 2F, 1F
};
// 保存灰度图(输入图像)
private Mat originalGrayMat;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityFilter2DactivityBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
// 初始化卷积核(默认)
values.clear();
values.addAll(Arrays.asList(FILTER_DEFAULT));
// 设置 RecyclerView 以 3x3 网格显示卷积核数值
mBinding.rvNumber.setLayoutManager(new GridLayoutManager(this, 3));
filter2DAdapter = new Filter2DAdapter(this, values);
mBinding.rvNumber.setAdapter(filter2DAdapter);
try {
// 读取测试图片(lena),转换为灰度图
Mat originalMat = Utils.loadResource(this, R.drawable.lena);
originalGrayMat = new Mat();
Imgproc.cvtColor(originalMat, originalGrayMat, Imgproc.COLOR_BGR2GRAY);
// 显示原始灰度图
showMat(mBinding.ivOriginalGray, originalGrayMat);
// 初始应用默认滤波
doFilter();
// 释放临时原图
originalMat.release();
} catch (Exception e) {
e.printStackTrace();
}
// 各种滤波按钮点击事件
mBinding.btnBlur.setOnClickListener(view -> setKernelAndFilter(FILTER_BLUR));
mBinding.btnEmboss.setOnClickListener(view -> setKernelAndFilter(FILTER_EMBOSS));
mBinding.btnIndentity.setOnClickListener(view -> setKernelAndFilter(FILTER_IDENTITY));
mBinding.btnSharpen.setOnClickListener(view -> setKernelAndFilter(FILTER_SHARPEN));
mBinding.btnOutline.setOnClickListener(view -> setKernelAndFilter(FILTER_OUTLINE));
mBinding.btnLeftSobel.setOnClickListener(view -> setKernelAndFilter(FILTER_LEFT_SOBEL));
mBinding.btnRightSobel.setOnClickListener(view -> setKernelAndFilter(FILTER_RIGHT_SOBEL));
mBinding.btnTopSobel.setOnClickListener(view -> setKernelAndFilter(FILTER_TOP_SOBEL));
mBinding.btnBottomSobel.setOnClickListener(view -> setKernelAndFilter(FILTER_BOTTOM_SOBEL));
// 自定义配置按钮(用户手动修改 RecyclerView 中的卷积核数值)
mBinding.btnConfig.setOnClickListener(view -> {
values.clear();
values.addAll(filter2DAdapter.getValues());
doFilter();
});
}
/**
* 设置当前卷积核并应用滤波
*/
private void setKernelAndFilter(Float[] kernel) {
values.clear();
values.addAll(Arrays.asList(kernel));
filter2DAdapter.setData(kernel);
doFilter();
}
/**
* 应用滤波操作
*/
private void doFilter() {
// 构建 kernel 数组(长度 9)
float[] kernelArray = new float[9];
for (int i = 0; i < 9; i++) {
kernelArray[i] = values.get(i);
}
// 创建 3x3 卷积核矩阵
Mat kernel = new Mat(3, 3, CvType.CV_32FC1);
kernel.put(0, 0, kernelArray);
// 输出结果 Mat
Mat result = new Mat();
Imgproc.filter2D(
originalGrayMat, // 输入图像
result, // 输出图像
-1, // ddepth=-1 → 输出与输入图像深度一致
kernel, // 卷积核
new Point(-1.0, -1.0), // 默认锚点(-1,-1 表示中心点)
2.0, // 可选的额外缩放因子
Core.BORDER_CONSTANT // 边界处理方式(常量填充)
);
// 显示滤波后的图像
showMat(mBinding.ivFilter2, result);
}
/**
* 将 Mat 转换为 Bitmap 并显示到 ImageView
*/
private void showMat(ImageView imageView, Mat mat) {
Mat displayMat = new Mat();
// 根据通道数调整格式,保证可显示
if (mat.channels() == 1) {
// 灰度图 → BGR
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_GRAY2BGR);
} else if (mat.channels() == 3) {
// BGR → RGB(Android 显示需要 RGB)
Imgproc.cvtColor(mat, displayMat, Imgproc.COLOR_BGR2RGB);
} else {
// 直接拷贝
mat.copyTo(displayMat);
}
// 转换为 Bitmap 并设置到 ImageView
android.graphics.Bitmap bitmap = android.graphics.Bitmap.createBitmap(
displayMat.cols(), displayMat.rows(), android.graphics.Bitmap.Config.ARGB_8888);
Utils.matToBitmap(displayMat, bitmap);
imageView.setImageBitmap(bitmap);
// 释放临时 Mat
displayMat.release();
}
@Override
protected void onDestroy() {
super.onDestroy();
// 释放原始灰度图,避免内存泄漏
if (originalGrayMat != null) {
originalGrayMat.release();
}
}
}
Filter2DAdapter.java
java
/**
* 卷积核 Filter 适配器 (RecyclerView 版)
* 用于显示 3x3 卷积核的每一个数值,支持修改
*/
public class Filter2DAdapter extends RecyclerView.Adapter<Filter2DAdapter.ViewHolder> {
private final Context mContext;
private final List<Float> mValues;
public Filter2DAdapter(Context context, List<Float> values) {
this.mContext = context;
this.mValues = new ArrayList<>(values);
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(mContext).inflate(R.layout.item_filter_2, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, @SuppressLint("RecyclerView") int position) {
Float value = mValues.get(position);
holder.editValue.setText(String.valueOf(value));
// 移除旧的监听器,避免 RecyclerView 复用导致多次触发
if (holder.textWatcher != null) {
holder.editValue.removeTextChangedListener(holder.textWatcher);
}
holder.textWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
try {
float newValue = Float.parseFloat(s.toString());
mValues.set(position, newValue);
} catch (NumberFormatException e) {
mValues.set(position, 0f); // 输入非法时默认 0
}
}
};
holder.editValue.addTextChangedListener(holder.textWatcher);
}
@Override
public int getItemCount() {
return mValues.size(); // 一般固定是 9
}
static class ViewHolder extends RecyclerView.ViewHolder {
EditText editValue;
TextWatcher textWatcher;
public ViewHolder(@NonNull View itemView) {
super(itemView);
editValue = itemView.findViewById(R.id.et_number);
}
}
/**
* 更新数据并刷新
*/
public void setData(Float[] newValues) {
mValues.clear();
for (Float f : newValues) {
mValues.add(f);
}
notifyDataSetChanged();
}
/**
* 获取当前卷积核数据
*/
public List<Float> getValues() {
return mValues;
}
}
