学习 Android (二十二) 学习 OpenCV (七)
在上一章节,我们对霍夫直线、霍夫圆、直线拟合、轮廓发现与绘制、轮廓面积与周长、轮廓外接多边形、判断轮廓的几何形状、凸包检测和凸缺陷有了一定的学习和了解,那我们接着继续学习 OpenCV 的相关知识。
37 模板匹配
37.1 什么是模板匹配
模板匹配(Template Matching)是一种在较大图像中搜索和查找模板图像位置的方法。其核心思想是通过滑动模板图像 over the source image,并在每个位置计算相似度度量,最终找到最匹配的位置。
数学原理:
模板匹配的数学本质是图像相关性和相似性计算 。对于源图像 III 和模板图像 TTT,在源图像的每个位置 (x,y)(x,y)(x,y) 计算相似度得分:
text
R(x,y)=∑x′,y′(T(x′,y′)−I(x+x′,y+y′))2
实际应用中,OpenCV 提供了多种匹配方法,每种方法有不同的计算公式和特性。
匹配方法:
OpenCV 提供了 6 种不同的匹配方法:
方法 | 公式 | 最佳匹配 | 说明 |
---|---|---|---|
TM_SQDIFF | R=∑(T−I)2R = \sum(T - I)^2R=∑(T−I)2 | 最小值 | 平方差匹配 |
TM_SQDIFF_NORMED | R=∑(T−I)2∑T2⋅∑I2R = \frac{\sum(T - I)^2}{\sqrt{\sum T^2 \cdot \sum I^2}}R=∑T2⋅∑I2 ∑(T−I)2 | 最小值 | 归一化平方差匹配 |
TM_CCORR | R=∑(T⋅I)R = \sum(T \cdot I)R=∑(T⋅I) | 最大值 | 相关匹配 |
TM_CCORR_NORMED | R=∑(T⋅I)∑T2⋅∑I2R = \frac{\sum(T \cdot I)}{\sqrt{\sum T^2 \cdot \sum I^2}}R=∑T2⋅∑I2 ∑(T⋅I) | 最大值 | 归一化相关匹配 |
TM_CCOEFF | R=∑(T′⋅I′)R = \sum(T' \cdot I')R=∑(T′⋅I′) | 最大值 | 相关系数匹配 |
TM_CCOEFF_NORMED | R=∑(T′⋅I′)∑T′2⋅∑I′2R = \frac{\sum(T' \cdot I')}{\sqrt{\sum T'^2 \cdot \sum I'^2}}R=∑T′2⋅∑I′2 ∑(T′⋅I′) | 最大值 | 归一化相关系数匹配 |
其中 T′=T−TˉT' = T - \bar{T}T′=T−Tˉ, I′=I−IˉI' = I - \bar{I}I′=I−Iˉ (Tˉ\bar{T}Tˉ 和 Iˉ\bar{I}Iˉ 是均值)
-
TM_SQDIFF
(平方差匹配)-
计算模板和图像窗口的像素差的平方和。
-
值越小,匹配越好。
-
缺点:对亮度变化非常敏感。
-
-
TM_SQDIFF_NORMED
(归一化平方差匹配)-
TM_SQDIFF
的归一化版本,结果在 [0,1] 之间。 -
更稳定,但依然受光照影响。
-
-
TM_CCORR
(相关匹配)-
计算模板和图像窗口的相关性。
-
值越大,匹配越好。
-
适合亮度一致的情况,但不做归一化,受图像强度影响。
-
-
TM_CCORR_NORMED
(归一化相关匹配)-
TM_CCORR
的归一化版本。 -
结果范围 [0,1],更健壮。
-
常用方法之一。
-
-
TM_CCOEFF
(相关系数匹配)-
考虑了均值的相关系数,能减弱亮度整体偏移的影响。
-
值越大,匹配越好。
-
-
TM_CCOEFF_NORMED
(归一化相关系数匹配)-
TM_CCOEFF
的归一化版本,范围 [-1,1]。 -
1
表示完全匹配,-1
表示完全不匹配。 -
最常用,鲁棒性较强。
-
工作流程:
-
准备阶段:加载源图像和模板图像
-
匹配阶段:使用选定的匹配方法计算相似度图
-
分析阶段:寻找相似度图的极值点(最小值或最大值)
-
结果可视化:在源图像上标记匹配区域
37.2 核心函数详解
核心函数
java
// 模板匹配核心函数
Imgproc.matchTemplate(Mat image, Mat templ, Mat result, int method)
参数说明:
-
image
:源图像,必须是8位或32位浮点图像 -
templ
:模板图像,不能大于源图像且类型相同 -
result
:匹配结果图,单通道32位浮点图像 -
method
:匹配方法(见上述6种方法)
辅助函数
java
// 查找矩阵中的最小值和最大值位置
Core.MinMaxLocResult Core.minMaxLoc(Mat mat)
// 归一化图像
Core.normalize(Mat src, Mat dst, double alpha, double beta, int norm_type)
// 图像转换
Imgproc.cvtColor(Mat src, Mat dst, int code) // 颜色空间转换
MinMaxLocResult 结构
java
Core.MinMaxLocResult result = Core.minMaxLoc(matchResult);
double minVal = result.minVal; // 最小值
double maxVal = result.maxVal; // 最大值
Point minLoc = result.minLoc; // 最小值位置
Point maxLoc = result.maxLoc; // 最大值位置
37.3 应用场景
-
工业视觉检测
-
产品质量控制:检测产品上的标志、标签或缺陷
-
零件定位:在装配线上精确定位零件
-
印刷品检测:检查印刷质量和对齐精度
-
-
文档处理
-
表单识别:识别和提取表单中的特定字段
-
印章检测:检测文档中的印章或签名区域
-
文档比对:查找不同版本文档间的差异
-
-
游戏与自动化
-
游戏机器人:自动识别游戏界面元素
-
UI自动化测试:验证应用程序界面元素位置
-
屏幕 scraping:从屏幕截图中提取特定信息
-
-
增强现实与计算机视觉
-
标记跟踪:识别和跟踪视觉标记
-
物体检测:在复杂场景中定位特定物体
-
图像检索:在图像库中查找相似图像
-
-
移动应用开发
-
实时相机处理:在相机预览中实时检测对象
-
图像编辑工具:提供基于模板的编辑功能
-
教育应用:开发视觉学习工具
-
37.4 示例
TemplateMatchingActivity.java
java
public class TemplateMatchingActivity extends AppCompatActivity {
private ActivityTemplateMatchingBinding mBinding;
static {
System.loadLibrary("opencv_java4"); // 加载 OpenCV 动态库
}
private Mat mOriginalMat, mTemplateMat; // 原始图像和模板图像
// 匹配方法(字符串显示给用户看)
private final String[] matchMethods = {
"TM_SQDIFF - 平方差匹配法",
"TM_SQDIFF_NORMED - 归一化平方差匹配法",
"TM_CCORR - 相关匹配法",
"TM_CCORR_NORMED - 归一化相关匹配法",
"TM_CCOEFF - 相关系数匹配法",
"TM_CCOEFF_NORMED - 归一化相关系数匹配法"
};
// 对应 OpenCV 内部的匹配方法常量
private final int[] methodValues = {
Imgproc.TM_SQDIFF, Imgproc.TM_SQDIFF_NORMED,
Imgproc.TM_CCORR, Imgproc.TM_CCORR_NORMED,
Imgproc.TM_CCOEFF, Imgproc.TM_CCOEFF_NORMED
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityTemplateMatchingBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
setupSpinner(); // 设置下拉框选择匹配方法
try {
// 加载原图和模板
mOriginalMat = Utils.loadResource(this, R.drawable.wuping);
mTemplateMat = Utils.loadResource(this, R.drawable.muban);
// 显示在 UI 上
OpenCVHelper.showMat(mBinding.ivSource, mOriginalMat);
OpenCVHelper.showMat(mBinding.ivTemplate, mTemplateMat);
// 单模板匹配按钮
mBinding.btnProcess.setOnClickListener(view -> {
if (checkImagesReady()) {
processSingleTemplateMatching();
}
});
// 多目标匹配按钮
mBinding.btnMultiMatch.setOnClickListener(view -> {
if (checkImagesReady()) {
processMultiTemplateMatching();
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 检查图像是否准备好
*/
private boolean checkImagesReady() {
if (mOriginalMat == null || mTemplateMat == null) {
Toast.makeText(this, "请先选择源图像和模板图像", Toast.LENGTH_SHORT).show();
return false;
}
// 模板尺寸不能大于源图
if (mTemplateMat.cols() > mOriginalMat.cols() || mTemplateMat.rows() > mOriginalMat.rows()) {
Toast.makeText(this, "模板图像不能大于源图像", Toast.LENGTH_SHORT).show();
return false;
}
return true;
}
/**
* 设置下拉框,选择模板匹配方法
*/
private void setupSpinner() {
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this, android.R.layout.simple_spinner_item, matchMethods);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
mBinding.spMethod.setAdapter(adapter);
mBinding.spMethod.setSelection(5); // 默认 TM_CCOEFF_NORMED
}
/**
* 单次模板匹配(找出最佳位置)
*/
private void processSingleTemplateMatching() {
try {
// 获取用户选择的匹配方法
int selectedMethod = methodValues[mBinding.spMethod.getSelectedItemPosition()];
// 结果矩阵大小 (W-w+1, H-h+1)
int resultCols = mOriginalMat.cols() - mTemplateMat.cols() + 1;
int resultRows = mOriginalMat.rows() - mTemplateMat.rows() + 1;
Mat result = new Mat(resultRows, resultCols, CvType.CV_32FC1);
// 执行模板匹配
long startTime = System.currentTimeMillis();
Imgproc.matchTemplate(mOriginalMat, mTemplateMat, result, selectedMethod);
long endTime = System.currentTimeMillis();
// 获取最小值/最大值以及对应的位置
Core.MinMaxLocResult mmr = Core.minMaxLoc(result);
Point matchLoc;
double matchValue;
boolean isMinBest = (selectedMethod == Imgproc.TM_SQDIFF ||
selectedMethod == Imgproc.TM_SQDIFF_NORMED);
// 对于平方差,最小值才是最佳匹配;其余方法最大值才是最佳匹配
if (isMinBest) {
matchLoc = mmr.minLoc;
matchValue = mmr.minVal;
} else {
matchLoc = mmr.maxLoc;
matchValue = mmr.maxVal;
}
// 克隆原图,用于绘制匹配结果
Mat displayMat = mOriginalMat.clone();
// 绘制矩形框
Point topLeft = matchLoc;
Point bottomRight = new Point(matchLoc.x + mTemplateMat.cols(),
matchLoc.y + mTemplateMat.rows());
Imgproc.rectangle(displayMat, topLeft, bottomRight, new Scalar(0, 255, 0), 4);
// 绘制中心点
Point center = new Point(matchLoc.x + mTemplateMat.cols() / 2,
matchLoc.y + mTemplateMat.rows() / 2);
Imgproc.circle(displayMat, center, 8, new Scalar(255, 0, 0), -1);
// 显示匹配置信度和位置
String infoText = String.format("Confidence level: %.4f Position: (%.0f,%.0f)",
matchValue, matchLoc.x, matchLoc.y);
Imgproc.putText(displayMat, infoText,
new Point(10, 30), Imgproc.FONT_HERSHEY_SIMPLEX,
1, new Scalar(0, 0, 255), 2);
// 显示耗时
String timeText = String.format("Processing Time: %dms", (endTime - startTime));
Imgproc.putText(displayMat, timeText,
new Point(10, 70), Imgproc.FONT_HERSHEY_SIMPLEX,
1, new Scalar(0, 255, 255), 2);
// 显示结果
OpenCVHelper.showMat(mBinding.ivResult, displayMat);
// 更新 TextView
String resultText = String.format("匹配方法: %s\n最佳位置: (%.0f, %.0f)\n置信度: %.4f\n处理时间: %dms",
matchMethods[mBinding.spMethod.getSelectedItemPosition()],
matchLoc.x, matchLoc.y, matchValue,
(endTime - startTime));
mBinding.tvResult.setText(resultText);
result.release();
displayMat.release();
} catch (Exception e) {
e.printStackTrace();
Toast.makeText(this, "模板匹配失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
/**
* 多目标模板匹配
*/
private void processMultiTemplateMatching() {
try {
int selectedMethod = methodValues[mBinding.spMethod.getSelectedItemPosition()];
boolean isMinBest = (selectedMethod == Imgproc.TM_SQDIFF ||
selectedMethod == Imgproc.TM_SQDIFF_NORMED);
// 结果矩阵
int resultCols = mOriginalMat.cols() - mTemplateMat.cols() + 1;
int resultRows = mOriginalMat.rows() - mTemplateMat.rows() + 1;
Mat result = new Mat(resultRows, resultCols, CvType.CV_32FC1);
long startTime = System.currentTimeMillis();
Imgproc.matchTemplate(mOriginalMat, mTemplateMat, result, selectedMethod);
long endTime = System.currentTimeMillis();
// 计算阈值(决定多少结果算匹配)
Core.MinMaxLocResult mmr = Core.minMaxLoc(result);
double threshold;
if (isMinBest) {
threshold = mmr.minVal * 1.5;
} else {
threshold = mmr.maxVal * 0.95;
}
// 扫描所有位置,找到超过阈值的点
List<Point> matches = new ArrayList<>();
for (int y = 0; y < result.rows(); y++) {
for (int x = 0; x < result.cols(); x++) {
double[] value = result.get(y, x);
if (value != null && value.length > 0) {
if ((isMinBest && value[0] <= threshold) ||
(!isMinBest && value[0] >= threshold)) {
matches.add(new Point(x, y));
}
}
}
}
// 使用非最大抑制,去掉重叠框
List<Point> finalMatches = nonMaxSuppression(matches, mTemplateMat.cols() / 2);
// 绘制结果
Mat displayMat = mOriginalMat.clone();
for (Point match : finalMatches) {
Point topLeft = match;
Point bottomRight = new Point(match.x + mTemplateMat.cols(),
match.y + mTemplateMat.rows());
Imgproc.rectangle(displayMat, topLeft, bottomRight, new Scalar(0, 255, 0), 3);
// 标号
Imgproc.putText(displayMat, String.valueOf(finalMatches.indexOf(match) + 1),
new Point(match.x + 5, match.y + 20),
Imgproc.FONT_HERSHEY_SIMPLEX, 0.7,
new Scalar(255, 255, 0), 2);
}
// 显示检测信息
String infoText = String.format("Found %d match Time: %dms",
finalMatches.size(), (endTime - startTime));
Imgproc.putText(displayMat, infoText,
new Point(10, 30), Imgproc.FONT_HERSHEY_SIMPLEX,
1, new Scalar(0, 0, 255), 2);
OpenCVHelper.showMat(mBinding.ivResult, displayMat);
mBinding.tvResult.setText(String.format("找到 %d 个匹配对象\n处理时间: %dms",
finalMatches.size(), (endTime - startTime)));
result.release();
displayMat.release();
} catch (Exception e) {
e.printStackTrace();
Toast.makeText(this, "多对象匹配失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
/**
* 非最大抑制:避免多个检测框重叠
*/
private List<Point> nonMaxSuppression(List<Point> matches, double minDistance) {
List<Point> result = new ArrayList<>();
for (Point match : matches) {
boolean isOverlap = false;
for (Point existing : result) {
double distance = Math.sqrt(Math.pow(match.x - existing.x, 2) +
Math.pow(match.y - existing.y, 2));
if (distance < minDistance) {
isOverlap = true;
break;
}
}
if (!isOverlap) {
result.add(match);
}
}
return result;
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mOriginalMat != null) OpenCVHelper.safeRelease(mOriginalMat);
if (mTemplateMat != null) OpenCVHelper.safeRelease(mTemplateMat);
}
}
-
模板匹配核心
-
Imgproc.matchTemplate(src, template, result, method)
-
结果矩阵
result
保存了模板在每个位置的匹配分数。 -
不同
method
决定是取最小值(平方差)还是取最大值(相关系数)。
-
-
单匹配
-
找到唯一最佳位置(min 或 max)。
-
用绿色矩形框框出模板在原图的位置。
-
额外绘制中心点和匹配置信度。
-
-
多匹配
-
扫描
result
全图。 -
根据阈值(不同方法不同)筛选多个候选点。
-
用 非最大抑制(NMS) 去掉重叠结果。
-
给每个检测到的对象画矩形并编号。
-
-
关键点
-
checkImagesReady()
确保模板图像不能比原图大。 -
nonMaxSuppression()
避免多个检测框堆叠。 -
spinner
控制用户切换不同的匹配方法。
-
38 二维码 QR 检测与识别
38.1 什么是 QR
QR码(Quick Response Code)是一种矩阵二维码,由日本Denso Wave公司于1994年发明。其结构包含以下关键部分:
-
位置探测图形:三个相同的位于角落的Finder Pattern,用于定位二维码
-
对齐图案:帮助校正扭曲的二维码
-
定时图案:黑白相间的线条,用于确定模块坐标
-
格式信息:包含纠错等级和掩模模式信息
-
版本信息:标识QR码的版本(1-40)
-
数据和纠错码:实际存储的数据和Reed-Solomon纠错码
-
安静区:二维码周围的空白区域,便于识别
QR码检测主要基于以下计算机视觉技术:
-
位置探测图形识别:
-
查找三个特定的Finder Pattern(黑白黑比例1:1:3:1:1)
-
使用轮廓分析、几何特征匹配和比例验证
-
-
透视变换校正:
-
通过三个Finder Pattern的位置计算透视变换矩阵
-
将倾斜的二维码校正为正面视角
-
-
模块解析:
-
根据版本信息确定模块数量和排列方式
-
读取格式信息和版本信息
-
解析数据模块并应用Reed-Solomon纠错
-
-
数据解码:
-
解析数据流(数字、字母数字、字节、汉字等模式)
-
应用纠错算法修复损坏的数据
-
38.2 核心函数详解
OpenCV提供了专门的QR码检测与识别类:
java
// QR码检测器主要类
org.opencv.objdetect.QRCodeDetector
核心方法:
java
// 检测并解码QR码
boolean decode(Mat img, Mat points, String decodedInfo)
// 检测QR码(不解码)
boolean detect(Mat img, Mat points)
// 多二维码检测
boolean detectAndDecodeMulti(Mat img, List<String> decodedInfo, List<Mat> points)
// 同时检测和解码
String detectAndDecode(Mat img, Mat points, Mat straight_qrcode)
参数说明:
-
img
:输入图像,灰度或彩色 -
points
:输出的QR码角点位置(4个点) -
decodedInfo
:解码后的字符串信息 -
straight_qrcode
:校正后的二维码图像
辅助方法:
java
// 绘制检测结果
Imgproc.line(Mat img, Point pt1, Point pt2, Scalar color) // 绘制边界线
Imgproc.putText(Mat img, String text, Point org, int fontFace, double fontScale, Scalar color) // 绘制文本
// 透视变换
Imgproc.getPerspectiveTransform(Mat src, Mat dst) // 获取透视变换矩阵
Imgproc.warpPerspective(Mat src, Mat dst, Mat M, Size dsize) // 应用透视变换
// 图像预处理
Imgproc.cvtColor(Mat src, Mat dst, int code) // 颜色空间转换
Imgproc.GaussianBlur(Mat src, Mat dst, Size ksize, double sigmaX) // 高斯模糊
Imgproc.threshold(Mat src, Mat dst, double thresh, double maxval, int type) // 阈值处理
38.3 应用场景
QR码在移动应用中有着广泛的应用场景:
-
商业与支付
-
移动支付:支付宝、微信支付等扫码支付
-
商品信息:扫描商品二维码获取详细信息
-
促销活动:扫描二维码参与促销或获取优惠券
-
-
信息传递
-
网址链接:快速访问网站或下载应用
-
联系方式:vCard格式的联系人信息交换
-
WiFi连接:扫描二维码自动连接WiFi网络
-
-
票务与身份验证
-
电子票务:演唱会、电影票、交通票务
-
身份认证:登录验证、门禁系统
-
会员管理:会员卡、积分系统
-
-
工业与物流
-
库存管理:商品追踪和管理
-
物流跟踪:包裹追踪和配送信息
-
设备管理:设备标识和维护信息
-
-
教育与生活
-
学习资源:链接到在线教育内容
-
公共场所:博物馆、公园的导览信息
-
疫情防控:健康码、行程码等
-
38.4 示例
QRCodeDetectionActivity.java
java
public class QRCodeDetectionActivity extends AppCompatActivity {
private ActivityQrcodeDetectionBinding mBinding;
static {
System.loadLibrary("opencv_java4"); // 加载 OpenCV 的 JNI 库
}
private Mat mOriginalMat, mGrayMat;
private QRCodeDetector qrCodeDetector = new QRCodeDetector(); // OpenCV 自带的二维码检测器
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityQrcodeDetectionBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
// 从资源文件加载一张图片
mOriginalMat = Utils.loadResource(this, R.drawable.byqc);
// 显示原始图像
OpenCVHelper.showMat(mBinding.ivOriginal, mOriginalMat);
// 转灰度,检测/解码 QR 更稳定
mGrayMat = new Mat();
Imgproc.cvtColor(mOriginalMat, mGrayMat, Imgproc.COLOR_BGR2GRAY);
detectQR(); // 检测 QR 二维码边界
decodeQR(); // 解码 QR 二维码内容
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 检测二维码边界,并在图像上绘制检测结果
*/
private void detectQR() {
Mat points = new Mat();
// detect() 只检测二维码是否存在,并返回角点
boolean detect = qrCodeDetector.detect(mGrayMat, points);
if (detect) {
try {
// 转换为彩色图,方便绘制彩色线条
Mat result = new Mat();
Imgproc.cvtColor(mGrayMat, result, Imgproc.COLOR_GRAY2BGR);
// points 是 1x4x2 矩阵,保存二维码的四个角点
if (points.cols() == 4) {
Point[] corners = new Point[4];
for (int i = 0; i < 4; i++) {
double[] pointData = points.get(0, i); // 获取单个角点 [x, y]
corners[i] = new Point(pointData[0], pointData[1]);
}
Log.e("detectQR", Arrays.toString(corners));
// 绘制二维码边界(闭合四边形)
for (int i = 0; i < 4; i++) {
Imgproc.line(result,
corners[i], // 当前角点
corners[(i + 1) % 4], // 下一个角点(最后一个连回第一个)
new Scalar(255, 0, 0), 4); // 蓝色线条,线宽4
}
// 在角点位置画绿色圆点
for (Point corner : corners) {
Imgproc.circle(result, corner, 4, new Scalar(0, 255, 0), 4);
}
// 显示结果图
OpenCVHelper.showMat(mBinding.ivDetect, result);
OpenCVHelper.safeRelease(result);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 解码二维码内容
*/
private void decodeQR() {
Mat points = new Mat();
boolean detect = qrCodeDetector.detect(mGrayMat, points);
if (detect) {
// decode() 尝试解析二维码的内容
String result = qrCodeDetector.decode(mGrayMat, points);
if (result != null) {
Log.e("ZJH", "解码结果为: " + result);
mBinding.tvResult.setText("解码结果为:\n" + result);
} else {
mBinding.tvResult.setText("解码结果为: 无法解码");
Toast.makeText(this, "无法解码", Toast.LENGTH_SHORT).show();
}
} else {
mBinding.tvResult.setText("解码结果为: 未检测到QRCode");
Toast.makeText(this, "未检测到QRCode", Toast.LENGTH_SHORT).show();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mOriginalMat != null) OpenCVHelper.safeRelease(mOriginalMat);
if (mGrayMat != null) OpenCVHelper.safeRelease(mGrayMat);
}
}

39. 图像分割:漫水填充法
图像分割就是把图像分成若干个特定的、具有独特性质的区域并提出感兴趣目标的技术和过程。它是由图像处理到图像分析的关键步骤。现有的图像分割方法主要分以下几类:基于阈值的分割方法、基于区域的分割方法、基于边缘的分割方法以及基于特定理论的分割方法等。从数学角度来看,图像分割是将数字图像划分成互不相交的区域的过程。图像分割的过程也是一个标记过程,即把属于同一区域的像素赋予相同的编号。
39.1 什么是漫水填充法
漫水填充法(Flood Fill)是一种基于区域生长的图像分割算法,其工作原理类似于画图软件中的"油漆桶"工具。算法从一个种子点开始,检查相邻像素的颜色相似性,将颜色相似的相邻像素填充为指定颜色。
算法原理:
-
选择一个种子点(起始点)
-
检查种子点的颜色值
-
检查相邻像素(4连通或8连通)的颜色是否在允许的容差范围内
-
将符合条件的像素填充为指定颜色
-
递归或迭代地继续这个过程,直到没有符合条件的相邻像素
39.2 核心函数详解
OpenCV中的漫水填充函数是Imgproc.floodFill()
,它有多个重载版本,最常用的如下:
java
public static int floodFill(
Mat image,
Mat mask,
Point seedPoint,
Scalar newVal,
Rect rect,
Scalar loDiff,
Scalar hiDiff,
int flags
)
参数说明:
-
image:输入/输出图像,可以是单通道或多通道
-
mask:操作掩码,必须比原图宽和高各大2个像素
-
seedPoint:填充起始点(种子点)
-
newVal:填充的新颜色值
-
rect:可选参数,设置填充区域的最小边界矩形
-
loDiff:当前像素与种子像素或相邻像素之间的最大下限差异
-
hiDiff:当前像素与种子像素或相邻像素之间的最大上限差异
-
flags:操作标志,控制填充行为
标志位(flags)详解 :
flags参数由三部分组成:
-
连通性:4(默认)或8连通
-
填充模式:FLOODFILL_FIXED_RANGE或FLOODFILL_MASK_ONLY
-
掩码值:填充掩码的值(0-255),左移8位
常用组合:
java
// 4连通,标准填充
int flags = 4;
// 8连通,固定范围填充
int flags = 8 | Imgproc.FLOODFILL_FIXED_RANGE;
// 只填充掩码,不修改原图
int flags = 4 | Imgproc.FLOODFILL_MASK_ONLY;
// 设置掩码填充值为128
int flags = 4 | (128 << 8);
39.3 应用场景
-
图像分割:将图像中颜色相似的区域分割出来
-
背景替换:填充背景区域以实现背景替换
-
目标提取:提取特定颜色的对象
-
图像编辑:实现类似油漆桶的填充功能
-
连通区域分析:标记和统计图像中的连通区域
39.4 示例
FloodFillActivity.java
java
public class FloodFillActivity extends AppCompatActivity {
private ActivityFloodFillBinding mBinding;
static {
System.loadLibrary("opencv_java4"); // 加载 OpenCV 库
}
private Mat mOriginalMat; // 原始图像
private int connectionType = 4; // 连通性 (4-连通 / 8-连通)
private int floodFillFlag = 0; // floodFill 的标志位
private double loDiff = 0.0; // 灰度下界差值
private double upDiff = 0.0; // 灰度上界差值
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityFloodFillBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
// 加载测试图片
mOriginalMat = Utils.loadResource(this, R.drawable.lxh);
OpenCVHelper.showMat(mBinding.ivOriginal, mOriginalMat);
} catch (Exception e) {
e.printStackTrace();
}
// loDiff 调节 (灰度差下限)
mBinding.sbLo.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
mBinding.tvLo.setText("" + i);
loDiff = i; // 设置下界
doFloodFill(); // 每次调整重新执行 floodFill
}
@Override public void onStartTrackingTouch(SeekBar seekBar) {}
@Override public void onStopTrackingTouch(SeekBar seekBar) {}
});
// upDiff 调节 (灰度差上限)
mBinding.sbUp.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
mBinding.tvUp.setText("" + i);
upDiff = i; // 设置上界
doFloodFill(); // 每次调整重新执行 floodFill
}
@Override public void onStartTrackingTouch(SeekBar seekBar) {}
@Override public void onStopTrackingTouch(SeekBar seekBar) {}
});
// 选择连通性:4 邻域 / 8 邻域
mBinding.rgColor.setOnCheckedChangeListener((radioGroup, checkedId) -> {
if (checkedId == R.id.rb_4) {
connectionType = 4;
} else if (checkedId == R.id.rb_8) {
connectionType = 8;
}
doFloodFill();
});
// 勾选 "Fixed Range" 开启固定范围模式
mBinding.cbRange.setOnCheckedChangeListener((compoundButton, b) -> {
if (b) {
floodFillFlag |= Imgproc.FLOODFILL_FIXED_RANGE;
} else {
floodFillFlag &= ~Imgproc.FLOODFILL_FIXED_RANGE;
}
doFloodFill();
});
// 勾选 "Mask Only" 只输出掩膜
mBinding.cbOnly.setOnCheckedChangeListener((compoundButton, b) -> {
if (b) {
floodFillFlag |= Imgproc.FLOODFILL_MASK_ONLY;
} else {
floodFillFlag &= ~Imgproc.FLOODFILL_MASK_ONLY;
}
doFloodFill();
});
// 初始执行一次
doFloodFill();
}
private void doFloodFill() {
try {
// 克隆一份原始图像,避免修改源图
Mat result = mOriginalMat.clone();
// 注意:mask 必须比原图大 2 个像素
Mat maskers = new Mat(mOriginalMat.rows() + 2, mOriginalMat.cols() + 2,
CvType.CV_8UC1, new Scalar(0));
// 种子点选在图像中心
Point seedPoint = new Point(mOriginalMat.cols() / 2.0, mOriginalMat.rows() / 2.0);
// 执行 FloodFill
Imgproc.floodFill(
result, // 输入图像(会被修改)
maskers, // 掩膜(必须大 2)
seedPoint, // 种子点
new Scalar(65.0, 105.0, 225.0), // 填充颜色 (BGR: RoyalBlue)
new Rect(), // 输出填充区域的边界矩形(可忽略)
new Scalar(loDiff, loDiff, loDiff), // loDiff:允许的下界差值
new Scalar(upDiff, upDiff, upDiff), // upDiff:允许的上界差值
connectionType | floodFillFlag // 标志位(连通性 + 选项)
);
// 判断是否只显示 MASK 掩膜
if ((floodFillFlag & Imgproc.FLOODFILL_MASK_ONLY) == Imgproc.FLOODFILL_MASK_ONLY) {
// mask 原始范围是 0/1,需要转化为 0/255 才能显示
Mat displayMask = new Mat();
Core.multiply(maskers, new Scalar(255), displayMask);
OpenCVHelper.showMat(mBinding.ivFillResult, displayMask);
OpenCVHelper.safeRelease(displayMask);
} else {
// 显示填充后的结果图
OpenCVHelper.showMat(mBinding.ivFillResult, result);
}
// 释放内存
OpenCVHelper.safeRelease(maskers);
OpenCVHelper.safeRelease(result);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mOriginalMat != null) OpenCVHelper.safeRelease(mOriginalMat);
}
}

40 图像分割:分水岭法
40.1 什么分水岭法
分水岭算法(Watershed Algorithm)是一种基于拓扑理论的数学形态学分割方法,其基本思想是将图像视为地理拓扑表面,其中像素的灰度值表示海拔高度。高灰度值对应山峰,低灰度值对应山谷。
算法原理
-
地理隐喻:将图像看作地形图,亮度值代表海拔
-
"注水"过程:从局部最小值开始注水,水会逐渐填满盆地
-
分水岭形成:当来自不同盆地的水相遇时,形成分水岭(边界)
-
分割结果:分水岭将图像分割成不同的区域
数学基础:
分水岭算法基于数学形态学中的距离变换和梯度计算,通过寻找图像的局部最小值和分水岭线来实现分割。
40.2 核心函数详解
OpenCV中的分水岭函数是Imgproc.watershed()
:
java
public static void watershed(Mat image, Mat markers)
参数说明:
-
image:输入图像,必须是8位3通道图像(CV_8UC3)
-
markers:输入/输出标记矩阵,数据类型为32位单通道整型(CV_32SC1)
标记矩阵(markers)的含义:
-
0:未知区域(算法将确定这些区域的归属)
-
正整数:表示不同的前景对象
-
-1:表示分水岭(边界)
40.3 应用场景
-
接触对象分离:分离相互接触的物体(如细胞、颗粒等)
-
图像分割:将图像分割成有意义的区域
-
医学图像处理:细胞计数、组织分割
-
工业检测:零件计数、缺陷检测
-
计算机视觉:对象识别和分割的预处理步骤
40.4 示例
WatershedActivity.java
java
public class WatershedActivity extends AppCompatActivity {
private ActivityWatershedBinding mBinding;
static {
System.loadLibrary("opencv_java4"); // 加载 OpenCV 库
}
private Mat mOriginalMat; // 原始图像 Mat
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityWatershedBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
// 加载原始图片
mOriginalMat = Utils.loadResource(this, R.drawable.lena);
OpenCVHelper.showMat(mBinding.ivOriginal, mOriginalMat);
Mat grayMat = new Mat();
// 1. 灰度化
Imgproc.cvtColor(mOriginalMat, grayMat, Imgproc.COLOR_RGB2GRAY);
// 2. 高斯滤波,去噪声
Imgproc.GaussianBlur(grayMat, grayMat, new Size(3, 3), 2);
// 3. 边缘检测 (Canny)
Imgproc.Canny(grayMat, grayMat, 80, 150);
// 4. 查找轮廓
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat(); // 层级关系
Imgproc.findContours(
grayMat, contours, hierarchy,
Imgproc.RETR_TREE, // 检测所有轮廓并重构层级
Imgproc.CHAIN_APPROX_SIMPLE, // 压缩水平、垂直、对角冗余点
new Point()
);
// 5. 创建空白 Mat
Mat imageContours = new Mat(mOriginalMat.size(), CvType.CV_8UC1); // 用于绘制轮廓
Mat marks = new Mat(mOriginalMat.size(), CvType.CV_32S); // 用于分水岭标记
marks.setTo(Scalar.all(0)); // 初始化为 0
int index = 0, compCount = 0;
// hierarchy 数据展开到数组
int[] hierarchyData = new int[(int) hierarchy.total() * hierarchy.channels()];
hierarchy.get(0, 0, hierarchyData);
// 6. 遍历轮廓并绘制到 marks / imageContours
for (; index >= 0; index = hierarchyData[index * 4], compCount++) {
// 绘制到 marks (作为分水岭算法输入的 marker)
Imgproc.drawContours(
marks, contours, index,
new Scalar(compCount + 1), // 每个区域标记不同整数
1, 8, hierarchy, Integer.MAX_VALUE, new Point()
);
// 绘制到 imageContours (仅用于显示)
Imgproc.drawContours(
imageContours, contours, index,
new Scalar(255), // 白色
1, 8, hierarchy, Integer.MAX_VALUE, new Point()
);
}
// 7. 分水岭算法执行
Imgproc.watershed(mOriginalMat, marks);
// 8. 转换标记矩阵为可视化图像
Mat afterWatershed = new Mat();
Core.convertScaleAbs(marks, afterWatershed);
OpenCVHelper.showMat(mBinding.ivWater, afterWatershed);
// 9. 生成随机颜色的分割结果
Mat perspectiveImage = Mat.zeros(mOriginalMat.size(), CvType.CV_8UC3);
for (int i = 0; i < marks.rows(); i++) {
for (int j = 0; j < marks.cols(); j++) {
double[] pixel = marks.get(i, j); // 获取标记值
int value = (int) pixel[0];
if (value == -1) {
// 分界线(-1 标记):白色
perspectiveImage.put(i, j, 255, 255, 255);
} else {
// 其它区域:赋予随机颜色
double[] color = randomColor(value);
perspectiveImage.put(i, j, color);
}
}
}
OpenCVHelper.showMat(mBinding.ivPush, perspectiveImage);
// 10. 原图与分割结果融合显示
Mat wshed = new Mat();
Core.addWeighted(mOriginalMat, 0.4, perspectiveImage, 0.6, 0, wshed);
OpenCVHelper.showMat(mBinding.ivResult, wshed);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 随机颜色生成函数
* @param value 分区标记值
* @return BGR 颜色数组
*/
private double[] randomColor(int value) {
value = value % 255; // 控制范围 0-255
Random rng = new Random();
int aa = rng.nextInt(value + 1); // 蓝通道
int bb = rng.nextInt(value + 1); // 绿通道
int cc = rng.nextInt(value + 1); // 红通道
return new double[]{aa, bb, cc}; // BGR 顺序
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mOriginalMat != null) OpenCVHelper.safeRelease(mOriginalMat); // 释放资源
}
}

41 图像分割:Grabcut
41.1 什么是 Grabcut
GrabCut 是一种基于图割(graph cut)的交互式图像分割算法,由 Microsoft Research 的 Rother 等人提出。与漫水填充相比,GrabCut 能够处理更复杂的图像分割任务,特别适合前景-背景分离。
算法原理:
-
初始化:用户指定一个包含前景的矩形区域或提供前景/背景的种子点
-
高斯混合模型(GMM):算法为前景和背景分别建立颜色分布模型
-
图割优化:将图像构建为图结构,通过最小化能量函数找到最佳分割
-
迭代优化:通过多次迭代不断优化GMM模型和分割结果
能量函数最小化:
GrabCut 最小化的能量函数包括:
-
数据项:衡量像素属于前景或背景的概率
-
平滑项:鼓励相邻相似像素具有相同标签
41.2 核心函数详解
OpenCV中的GrabCut函数是Imgproc.grabCut()
:
java
public static void grabCut(
Mat img,
Mat mask,
Rect rect,
Mat bgdModel,
Mat fgdModel,
int iterCount,
int mode
)
参数说明:
-
img:输入图像,必须是8位3通道图像(CV_8UC3)
-
mask:输入/输出掩码,尺寸与图像相同,指定像素的初始标签
-
rect:包含前景的矩形区域(如果使用矩形初始化)
-
bgdModel:背景模型的临时数组
-
fgdModel:前景模型的临时数组
-
iterCount:算法迭代次数
-
mode:操作模式,决定如何初始化掩码
掩码(mask)值含义:
java
// 定义在Imgproc类中
public static final int GC_BGD = 0; // 明确背景
public static final int GC_FGD = 1; // 明确前景
public static final int GC_PR_BGD = 2; // 可能背景
public static final int GC_PR_FGD = 3; // 可能前景
操作模式(mode):
java
public static final int GC_INIT_WITH_RECT = 0; // 使用矩形初始化
public static final int GC_INIT_WITH_MASK = 1; // 使用掩码初始化
public static final int GC_EVAL = 2; // 重新评估
41.3 应用场景
-
人物抠图:从复杂背景中提取人物
-
物体提取:分离图像中的特定物体
-
背景替换:为图像更换背景
-
图像编辑:Photoshop-like的智能选择工具
-
计算机视觉预处理:为其他算法提供精确的前景掩码
41.4 示例
GrabCutActivity.java
java
public class GrabCutActivity extends AppCompatActivity {
private ActivityGrabCutBinding mBinding;
static {
System.loadLibrary("opencv_java4");
}
private Mat mOriginalMat;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityGrabCutBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
mOriginalMat = Utils.loadResource(this, R.drawable.lena);
OpenCVHelper.showMat(mBinding.ivOriginal, mOriginalMat);
new Thread(() -> doBgdGrabCut()).start();
new Thread(() -> doFgdGrabCut()).start();
} catch (Exception e) {
e.printStackTrace();
}
}
private void doBgdGrabCut() {
try {
// 拷贝一份用于绘制矩形
Mat rectMat = new Mat();
mOriginalMat.copyTo(rectMat);
// 定义矩形 ROI
Rect rect = new Rect(80, 30, 340, 390);
Imgproc.rectangle(rectMat, rect, Scalar.all(255.0), 2);
// 在 UI 线程显示矩形框
runOnUiThread(() -> OpenCVHelper.showMat(mBinding.ivOriginal, rectMat));
// 创建背景/前景模型
Mat bgdModel = Mat.zeros(1, 65, CvType.CV_64FC1);
Mat fgdModel = Mat.zeros(1, 65, CvType.CV_64FC1);
// 初始化 mask
Mat mask = Mat.zeros(mOriginalMat.size(), CvType.CV_8UC1);
// 调用 GrabCut
Imgproc.grabCut(mOriginalMat, mask, rect, bgdModel, fgdModel,
5, Imgproc.GC_INIT_WITH_RECT);
// 遍历 mask,把背景标记为 255,前景为 0
for (int i = 0; i < mask.rows(); i++) {
for (int j = 0; j < mask.cols(); j++) {
double[] value = mask.get(i, j);
int v = (int) value[0];
if (v == Imgproc.GC_BGD || v == Imgproc.GC_PR_BGD) {
mask.put(i, j, 255.0); // 背景
} else {
mask.put(i, j, 0.0); // 前景
}
}
}
// 通过 mask 提取背景
Mat result = new Mat();
Core.bitwise_and(mOriginalMat, mOriginalMat, result, mask);
// 在 UI 线程显示结果
runOnUiThread(() -> OpenCVHelper.showMat(mBinding.ivBgd, result));
// 释放临时资源
rectMat.release();
mask.release();
bgdModel.release();
fgdModel.release();
} catch (Exception e) {
e.printStackTrace();
}
}
private void doFgdGrabCut() {
try {
// 拷贝一份用于绘制矩形
Mat rectMat = new Mat();
mOriginalMat.copyTo(rectMat);
// 定义矩形 ROI
Rect rect = new Rect(80, 30, 340, 390);
Imgproc.rectangle(rectMat, rect, Scalar.all(255.0), 2);
// 在 UI 线程显示矩形框
runOnUiThread(() -> OpenCVHelper.showMat(mBinding.ivOriginal, rectMat));
// 创建背景/前景模型
Mat bgdModel = Mat.zeros(1, 65, CvType.CV_64FC1);
Mat fgdModel = Mat.zeros(1, 65, CvType.CV_64FC1);
// 初始化 mask
Mat mask = Mat.zeros(mOriginalMat.size(), CvType.CV_8UC1);
// 调用 GrabCut
Imgproc.grabCut(mOriginalMat, mask, rect, bgdModel, fgdModel,
5, Imgproc.GC_INIT_WITH_RECT);
// 遍历 mask,将前景和可能的前景标记为 255,其他设为 0
for (int i = 0; i < mask.rows(); i++) {
for (int j = 0; j < mask.cols(); j++) {
double[] value = mask.get(i, j);
int v = (int) value[0];
if (v == Imgproc.GC_FGD || v == Imgproc.GC_PR_FGD) {
mask.put(i, j, 255.0);
} else {
mask.put(i, j, 0.0);
}
}
}
// 通过 mask 提取前景
Mat result = new Mat();
Core.bitwise_and(mOriginalMat, mOriginalMat, result, mask);
// 在 UI 线程显示结果
runOnUiThread(() -> OpenCVHelper.showMat(mBinding.ivFgd, result));
// 释放临时资源
rectMat.release();
mask.release();
bgdModel.release();
fgdModel.release();
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mOriginalMat != null) OpenCVHelper.safeRelease(mOriginalMat);
}
}

42 图像修复
42.1 什么是图像修复
图像修复(Image Inpainting)是一种用于修复图像中损坏或缺失部分的技术,其目标是使修复后的区域看起来自然且与周围环境无缝融合。OpenCV提供了两种主要的图像修复算法:
基于快速行进方法(Telea算法):
-
原理:基于快速行进方法(Fast Marching Method),从已知区域向未知区域传播信息
-
工作方式:算法从损坏区域的边界开始,逐步向内填充,使用周围像素的加权平均值
-
特点:速度较快,适合小到中等大小的修复区域
基于 Navier-Stokes 方程(NS算法):
-
原理:基于流体动力学和偏微分方程,模拟"等照度线"(相同强度的线)的传播
-
工作方式:将图像修复视为流体动力学问题,保持边缘连续性
-
特点:通常产生更平滑的结果,适合较大的修复区域
42.2 核心函数详解
OpenCV中的图像修复函数是photo.inpaint()
:
java
public static void inpaint(Mat src, Mat inpaintMask, Mat dst, double inpaintRadius, int flags)
参数说明:
-
src:输入图像,必须是8位单通道或3通道图像
-
inpaintMask:修复掩码,8位单通道图像,非零像素表示需要修复的区域
-
dst:输出图像,与输入图像相同的尺寸和类型
-
inpaintRadius:修复半径,算法考虑的每个受损像素周围的圆形邻域的半径
-
flags:修复方法,可以是:
-
INPAINT_TELEA
:基于Telea的算法 -
INPAINT_NS
:基于Navier-Stokes的算法
-
42.3 应用场景
-
老照片修复:去除照片中的划痕、污渍和折痕
-
水印去除:从图像中移除不需要的文字或标志
-
对象移除:移除图像中的不需要的物体或人物
-
图像编辑:修复红眼、瑕疵或其他缺陷
-
文档修复:修复扫描文档中的损坏部分
-
视频修复:修复视频帧中的损坏区域
42.4 示例
InpaintingActivity.java
java
public class InpaintingActivity extends AppCompatActivity {
private ActivityInpaintingBinding mBinding;
static {
// 加载 OpenCV 动态库(opencv_java4)
System.loadLibrary("opencv_java4");
}
// 保存原始图像的 Mat 对象
private Mat mOriginalMat;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = ActivityInpaintingBinding.inflate(getLayoutInflater());
setContentView(mBinding.getRoot());
try {
// 加载资源图片(R.drawable.lena2)并转换为 Mat 格式
mOriginalMat = Utils.loadResource(this, R.drawable.lena2);
// 显示原始图像
OpenCVHelper.showMat(mBinding.ivOriginal, mOriginalMat);
// 将原图转为灰度图
Mat grayMat = new Mat();
Imgproc.cvtColor(mOriginalMat, grayMat, Imgproc.COLOR_RGB2GRAY);
// 创建掩膜图像(单通道,初始全黑)
Mat imageMask = new Mat(mOriginalMat.size(), CvType.CV_8UC1, Scalar.all(0));
// 将灰度图中亮度 > 240 的像素点设为白色(255),其余为黑色
// 即提取出高亮区域作为修复的掩膜
Imgproc.threshold(grayMat, imageMask, 240, 255, Imgproc.THRESH_BINARY);
// 创建 3x3 的矩形结构元素(用于膨胀操作)
Mat Kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3));
// 对掩膜进行膨胀,扩大白色区域范围,方便修复效果更明显
Imgproc.dilate(imageMask, imageMask, Kernel);
// 使用 OpenCV 的图像修复算法 (Inpainting)
// 参数:
// mOriginalMat - 原图
// imageMask - 掩膜图(白色区域为修复目标)
// mOriginalMat - 输出图像(直接覆盖原图)
// 5 - 修复半径
// Photo.INPAINT_TELEA - Telea 算法(另一种是 Photo.INPAINT_NS)
Photo.inpaint(mOriginalMat, imageMask, mOriginalMat, 5, Photo.INPAINT_TELEA);
// 显示掩膜图
OpenCVHelper.showMat(mBinding.ivMask, imageMask);
// 显示修复后的图像
OpenCVHelper.showMat(mBinding.ivFix, mOriginalMat);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
// 回收 Mat,避免内存泄漏
if (mOriginalMat != null) OpenCVHelper.safeRelease(mOriginalMat);
}
}
