〔两行哥〕OpenCV4Android入门教程之API系列(一)

OpenCV是一个基于C与C++的跨平台计算机视觉处理库,高效且轻便,支持多平台,参考:OpenCV支持平台及相关介绍。目前国内网络上充斥着各种OpenCV“教程”,但大多基于C++语言,或者Python语言,使用的OpenCV版本也非常老旧,而国外的OpenCV官网上关于Android平台的介绍也寥寥无几,大部分讲解了OpenCV内部的算法原理而非API,对于我们Android开发者(使用Kotlin或Java)来说,想入门OpenCV似乎相当困难。

在这里两行哥给大家带来OpenCV4Android的入门教程,主要分为API系列和算法系列。API系列主要聚焦常用API及用法(略小白),帮助Android开发者快速完成企业开发需求。算法系列主要聚焦OpenCV内部数学算法,供有一定数学基础的开发者一窥究竟。

让我们以一个简单的答题卡识别Demo为例,开始OpenCV4Android的入门之旅吧。

一、开发环境

(一)基于OpenCV最新版(V3.4.1):OpenCV SDK下载地址

OpenCV SDK有两个分支3.X和2.X。3.X和2.X,除了集成方式不同外,3.X在某些场景下的效率也有所提升。具体区别各位读者可以自己查阅相关资料。本教程基于V3.4.1。

(二)基于AndroidStudio

请不要再看任何用着Eclipse开发Android的教程,实在太古老了。本文使用的是AS 3.1.2。

(三)基于Java

本来想用Kotlin,想想受众,还是用Java吧,用Kotlin开发Android的基本上都会Java,反过来,用Java开发Android的不一定都会Kotlin。

二、OpenCV SDK集成

相信我,真没有其他人写的那么复杂,不需要那么多种集成方式,那是基于2.X版本的。对于3.X版本的集成,很简单,大家不要有畏难心理。让我们建立一个Demo工程用于研究和学习OpenCV4Android。

(一)建立工程

建立一个Demo工程,然后根据上文提供的下载地址,下载OpenCV4AndroidSDK,如图1。


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第1张图片
图1 下载SDK.png

下载完成后解压,看一下解压后的内容,如图2。


图2 SDK文件夹.png

这里一共有3个文件夹:
1.samples:先看第2个文件夹,这里都是例子工程,包括了编译好的apk文件和项目源码。

2.apk:第1个文件夹里面是针对各个架构编译好OpenCVManager.apk文件,这是干什么用的呢?因为OpenCV中的jni库体积比较大,需要集成到App中,如果用户手机中有很多个使用到OpenCV库的App,那么这些App就可以不集成OpenCV的jni库,直接安装对应架构的OpenCVManager,OpenCVManager内就含有OpenCV需要的jni库,所有的App通过OpenCVManager共享一份jni库就好了,而且OpenCVManager可以很方便地管理和升级jni库。那么这些App是如何和OpenCVManager通讯的呢?看一下OpenCVLibrary源码就知道了,嗯,aidl。说了这么多,到底有什么用呢?假如我们要运行第2个文件夹samples中OpenCV的Demo,你会发现这些Demo体积都非常小,因为它们都没集成OpenCV的jni库。我们装上这十来个Demo后,再装上对应架构的OpenCVManager,它们就能愉快地共用一份jni库了......实际开发中不会这么做,一定是把OpenCV的jni库集成到项目内部的,直接使用。我们总不能要求用户装好我们的App,然后引导用户到OpenCV的官网或者其他地方去下载OpenCVManager吧?大概是脑抽,觉得自己的App活腻了,用户也觉得你的App实在是欠删。好了,说了这么多,请容许两行哥调皮一下,在实际开发中,第一个文件夹里的东西......其实没什么卵用。
3.sdk:第3个文件夹就是我们要的sdk了。
首先,我们将第3个文件夹sdk中的java文件夹作为module导入到我们建立好的Demo工程中,如图3、图4所示。


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第2张图片
图3 导入Module.png

〔两行哥〕OpenCV4Android入门教程之API系列(一)_第3张图片
图4 Module路径.png

导入完成后,配置项目依赖,如图5、图6所示。
〔两行哥〕OpenCV4Android入门教程之API系列(一)_第4张图片
图5 配置项目依赖关系1.png

〔两行哥〕OpenCV4Android入门教程之API系列(一)_第5张图片
图6 配置项目依赖关系2.png

然后进入openCVLibrary341包中的build.gradle,修改编译版本,与app包中的build.gradle一致,如图7所示。
〔两行哥〕OpenCV4Android入门教程之API系列(一)_第6张图片
图7 配置Android SDK VERSION.png

接着配置libs,复制图8所示目录下所有的文件夹(也可以根据项目需要复制文件):
图8 复制Libs.png

到jniLibs包下,通常jniLibs包在位于图9所示的main包下,如果jniLibs包不存在则新建一个。如果之前你自定义了jniLibs包的路径,请复制到你自定义的包路径下,不要生搬硬套。


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第7张图片
图9 粘贴Libs.png
这里提醒一下,如果你的项目之前就引入过其他jniLibs,则之前引入过多少种架构,这里也选择性地粘贴同样的架构文件夹,多一个不行,少一个也不行。如:之前有armeabi和armeabi-v7a,那么这次你也要选择这两个架构文件夹粘贴就好了,否则在运行时会发生崩溃,提示找不到对应架构的jni库。

顺便插一嘴,通常情况下我们选择armeabi、armeabi-v7a和arm64-v8a就够了,如果为了App体积考虑,只引入armeabi也够了。具体各种架构有什么区别,对应什么CPU,两行哥这里就不详细解释了,请读者自行查阅相关资料。在Demo中,我们引入了所有的架构。
复制完毕后,我们配置一下jniLibs路径,如图10所示,添加jniLibs.srcDirs参数。


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第8张图片
图10 配置jinLibs包路径.png

强烈建议同时配置支持的ndk类型,如图11所示,引入了多少架构包,就写多少种类型。


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第9张图片
图11 设置ndk支持的架构.png
如果项目通过implementation(即compile,compile关键词在新版本AS和Gradle中已经过时,使用implementation关键词代替)编译了某个使用了jniLibs的第三方库,这个第三方库支持的架构比你现有项目中架构多,运行时一样会发生上文所说的崩溃现象,就需要进行图11的配置。

例如:项目通过compile "com.github.pqpo:SmartCropper:v1.1.3"方式引入了SmartCropper这个第三方库,SmartCropper库引入并支持了多种架构,而你的项目仅仅引入了armeabi架构包,那么就可能在运行时发生崩溃。此时你需要在图11的位置配置abiFilters "armeabi",不写其他架构,表示项目仅仅支持armeabi架构,而不支持其他架构。
至此,SDK配置已经完成,让我们开始运行Demo吧。
在MainActivity中添加如下代码:

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";

    private LoaderCallbackInterface mLoaderCallback;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initOpenCV();
    }

    public void initOpenCV() {
        mLoaderCallback = new BaseLoaderCallback(getApplicationContext()) {
            @Override
            public void onManagerConnected(int status) {
                switch (status) {
                    case LoaderCallbackInterface.SUCCESS: {
                        Log.e(TAG, "OpenCV loaded successfully");
                    }
                    break;
                    default: {
                        super.onManagerConnected(status);
                    }
                    break;
                }
            }

            @Override
            public void onPackageInstall(int operation, InstallCallbackInterface callback) {

            }
        };
        if (!OpenCVLoader.initDebug()) {
            Log.e(TAG, "Internal OpenCV library not found. Using OpenCV Manager for initialization");
            OpenCVLoader.initAsync(OpenCVLoader.OPENCV_VERSION, getApplicationContext(), mLoaderCallback);
        } else {
            Log.e(TAG, "OpenCV library found inside package. Using it!");
            mLoaderCallback.onManagerConnected(LoaderCallbackInterface.SUCCESS);
        }
    }
}

这里的mLoaderCallback就是初始化OpenCV的监听回调,如果你在Logcat中看到了Log.e(TAG, "OpenCV loaded successfully")输出的日志,那么恭喜你,OpenCV已经集成成功。

(二)实例Demo

在这里以两行哥近期写的答题卡识别Demo为例进行初步介绍。先看效果图:


图12 示例图.gif

这里也提供一下答题卡原图,可以打印出来,填涂,并测试识别效果:


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第10张图片
图13 空白答题卡.jpg

提供一下部分填涂的答题卡,可以用来识别或者拍照后再识别,模拟用户实际使用:


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第11张图片
图14 部分填涂的答题卡.jpg
1.使用步骤
(1)参数配置

首先进入AnswerSheetConfig.java类中配置相关参数:

    //*******************************参数配置区域*******************************//
    //答题区域宽度
    private static final float TARGET_AREA_WIDTH = 1397F;
    //答题区域高度
    private static final float TARGET_AREA_HEIGHT = 1015F;
    //左边距
    private static final float OFFSET_LEFT = 15F;
    //右边距
    private static final float OFFSET_RIGHT = 4F;
    //上边距
    private static final float OFFSET_TOP = 44F;
    //下边距
    private static final float OFFSET_BOTTOM = 2F;
    //每栏中间分隔距离
    private static final float EMPTY_COL_WIDTH = 60F;
    //每大行中间分隔距离
    private static final float EMPTY_ROW_HEIGHT = 44F;
    //每栏题数
    public static final int PER_COL_COUNT = 5;
    //每大行选项数
    public static final int PER_ROW_COUNT = 4;
    //总列数
    public static final int TOTAL_COL_COUNT = 20;
    //总行数
    public static final int TOTAL_ROW_COUNT = 20;
    //可接受的最大偏差因子:二值化后的图像中,只要该选项的测量区域均值小于所有选项的测量区域均值的最大值的(LIMIT_ACCEPT_MAX_FACTOR*100)%,即认为该选项未被选中
    public static final float LIMIT_ACCEPT_MAX_FACTOR = 0.55F;
    //可接受的最小偏差因子:二值化后的图像中,只要所有选项的测量区域均值的最大值与均值的最小值相差小于255 * LIMIT_ACCEPT_MIN_FACTOR,即认为该最大值无效(用户没有涂答题卡,或把答题卡所有题的所有选项都涂满)
    public static final float LIMIT_ACCEPT_MIN_FACTOR = 0.25F;
    //每个选项测量的中间收缩因子:每个选项区域的四条边都向内部收缩(OPTION_SHRINK_FACTOR*100)%而形成的新区域作为被测量区域
    public static final float OPTION_SHRINK_FACTOR = 0.10F;
    //*******************************参数配置区域*******************************//

如果使用上文示例的答题卡,以上参数就可以直接使用。如果想使用自己设计的答题卡,请确保有准确清晰的答题卡扫描件,以备测量。对答题卡填涂区域(ROI区域,即有处理需求的区域)进行分割,剔除绿色部分,然后将剩下的区域,按照题目和选项平均分割,得到图15所示的效果:


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第12张图片
图15 分割ROI区域

在理解图15的基础上,理解每个参数的含义,如图16:


〔两行哥〕OpenCV4Android入门教程之API系列(一)_第13张图片
图16 参数含义.jpg

测量答题卡扫描件图片,将图16中需要的参数以像素点为单位,填入配置文件。还有三个参数LIMIT_ACCEPT_MAX_FACTOR、LIMIT_ACCEPT_MIN_FACTOR、OPTION_SHRINK_FACTOR,具体含义见注释。其中第一参数比较重要,设置的值比较高,则要求用户填涂答题卡的浓度要越黑,浓度比较低的填涂将不会被识别,如果设置的比较低,用户填涂答题卡比较淡也可以被识别,但是容易出现误识别(未填涂也被认为是已填涂)。另外两个参数可以暂不修改。
(2)使用Demo。

来份答题卡,涂一涂,然后按照图12进行操作:用相机拍摄答题卡或从相册获取答题卡的照片。请留意,一定要按照图12中的操作,准确选中答题卡的填涂区域(ROI区域,红色框线部分),否则识别会错误。
为什么这里没有采用OpenCV的轮廓识别自动帮用户选中ROI区域呢?这是斟酌取舍的结果。我们处理的是一张随手拍的照片,而非读卡机或者扫描仪得到的准确答题卡图片。这样OpenCV可能因为照片的光影、拍摄角度、拍摄时是否抖动(拍糊了)、成像质量等等原因而出现轮廓识别翻车的情况,因此使用了人工选择ROI区域。如果我们使用了精准的扫描件,图片中仅有答题卡,那么这样的扫描件进行轮廓识别就比较准确了。

2.内部原理

看一下核心逻辑:

    @Override
    public void dealWithPhoto(final File file) {
        //总步骤数
        final int total = 5;

        ThreadUtils.runOnSubThread(new Runnable() {
            @Override
            public void run() {
                Bitmap srcBitmap = BitmapFactory.decodeFile(file.getAbsolutePath());

                if (srcBitmap == null) {
                    return;
                }

                Mat srcMat = new Mat();
                Utils.bitmapToMat(srcBitmap, srcMat);

                AnswerSheetModel answerSheet = new AnswerSheetModel(srcMat.width(), srcMat.height());

                //1灰度化
                Mat grayMat = doGray(srcBitmap, srcMat, 1, total);

                //2模糊降噪
                Mat blurMat = doBlur(srcBitmap, grayMat, 2, total);

                //3二值化
                Mat binaryMat = doBinary(srcBitmap, blurMat, 3, total);

                //4绘制参考线
                Mat measureMat = doMeasure(srcBitmap, binaryMat, answerSheet, 4, total);

                //5识别填涂位置
                checkAnswer(measureMat, answerSheet, 5, total);

                srcMat.release();
                grayMat.release();
                blurMat.release();
                binaryMat.release();
                measureMat.release();
            }
        });
    }

    private void checkAnswer(Mat preMat, AnswerSheetModel answerSheet, int current, int total) {
        final String stepName = "识别填涂位置";
        stepDealStart(current, total, stepName);
        try {
            for (int i = 0; i < answerSheet.answerRows.size(); i++) {
                AnswerRowModel answerRow = answerSheet.answerRows.get(i);
                for (int j = 0; j < answerRow.answers.size(); j++) {
                    AnswerSheetItemModel answerItem = answerRow.answers.get(j);
                    for (int k = 0; k < answerItem.points.length; k++) {
                        if (k % 2 == 0 && k < answerItem.points.length - 1) {
                            Point tlPoint = answerItem.points[k];
                            Point brPoint = answerItem.points[k + 1];
                            float offsetX = answerSheet.answerWidth * AnswerSheetConfig.OPTION_SHRINK_FACTOR;
                            float offsetY = answerSheet.answerHeight * AnswerSheetConfig.OPTION_SHRINK_FACTOR;
                            Point targetTlPoint = new Point(tlPoint.x + offsetX, tlPoint.y + offsetY);
                            Point targetBrPoint = new Point(brPoint.x - offsetX, brPoint.y - offsetY);
                            Mat roiMat = preMat.submat(new Rect(targetTlPoint, targetBrPoint));
                            MatOfDouble meanMat = new MatOfDouble();
                            MatOfDouble stdDevMat = new MatOfDouble();
                            double[] mean = new double[1];
                            double[] stdDev = new double[1];
                            Core.meanStdDev(roiMat, meanMat, stdDevMat);
                            meanMat.get(0, 0, mean);
                            stdDevMat.get(0, 0, stdDev);
                            float factor = formatDouble(mean[0]);
                            switch (k / 2) {
                                case 0:
                                    answerItem.factorA = factor;
                                    break;
                                case 1:
                                    answerItem.factorB = factor;
                                    break;
                                case 2:
                                    answerItem.factorC = factor;
                                    break;
                                case 3:
                                    answerItem.factorD = factor;
                                    break;
                            }
                            meanMat.release();
                            stdDevMat.release();
                            roiMat.release();
                        }
                    }
                }
            }
            List answers = new ArrayList<>();
            List factors = new ArrayList<>();
            for (AnswerRowModel answerRow : answerSheet.answerRows) {
                for (AnswerSheetItemModel answer : answerRow.answers) {
                    factors.add(answer.factorA);
                    factors.add(answer.factorB);
                    factors.add(answer.factorC);
                    factors.add(answer.factorD);
                }
            }
            Collections.sort(factors);
            float maxFactor = factors.get(factors.size() - 1) > 255F * AnswerSheetConfig.LIMIT_ACCEPT_MAX_FACTOR ? 255F * AnswerSheetConfig.LIMIT_ACCEPT_MAX_FACTOR : factors.get(factors.size() - 1);
            float minFactor = factors.get(0);
            float limitMaxFactor = maxFactor * AnswerSheetConfig.LIMIT_ACCEPT_MAX_FACTOR;
            boolean limitMaxFactorIsValid = (maxFactor - minFactor) > 255F * AnswerSheetConfig.LIMIT_ACCEPT_MIN_FACTOR;
            for (AnswerRowModel answerRow : answerSheet.answerRows) {
                for (AnswerSheetItemModel answer : answerRow.answers) {
                    answer.checkA = answer.factorA > limitMaxFactor && limitMaxFactorIsValid;
                    answer.checkB = answer.factorB > limitMaxFactor && limitMaxFactorIsValid;
                    answer.checkC = answer.factorC > limitMaxFactor && limitMaxFactorIsValid;
                    answer.checkD = answer.factorD > limitMaxFactor && limitMaxFactorIsValid;
                    answers.add(answer);
                }
            }
            stepDealComplete(current, total, stepName, null, answers, true);
        } catch (Exception e) {
            stepDealComplete(current, current, stepName + "失败:" + e.getMessage(), null, null, false);
        }
    }

    @NonNull
    private Mat doMeasure(Bitmap srcBitmap, Mat preMat, AnswerSheetModel answerSheet, int current, int total) {
        final String stepName = "内容分析";
        stepDealStart(current, total, stepName);
        final Bitmap measureBitmap = createBitmapAsSrc(srcBitmap);
        Mat measureMat = preMat.clone();
        try {
            calcReferenceAxis(measureMat, answerSheet);
            Utils.matToBitmap(measureMat, measureBitmap);
            stepDealComplete(current, total, stepName, measureBitmap, null, true);
        } catch (Exception e) {
            measureMat.release();
            stepDealComplete(current, current, stepName + "失败:" + e.getMessage(), null, null, false);
        }
        return measureMat;
    }

    @NonNull
    private Mat doBinary(Bitmap srcBitmap, Mat preMat, final int current, final int total) {
        final String stepName = "二值化";
        stepDealStart(current, total, stepName);
        final Bitmap binaryBitmap = createBitmapAsSrc(srcBitmap);
        Mat binaryMat = new Mat();
        try {
            Imgproc.adaptiveThreshold(preMat, binaryMat, 255, Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY_INV, 225, 40);
            Utils.matToBitmap(binaryMat, binaryBitmap);
            stepDealComplete(current, total, stepName, binaryBitmap, null, true);
        } catch (Exception e) {
            binaryMat.release();
            stepDealComplete(current, current, stepName + "失败:" + e.getMessage(), null, null, false);
        }
        return binaryMat;
    }

    @NonNull
    private Mat doBlur(Bitmap srcBitmap, Mat preMat, final int current, final int total) {
        final String stepName = "高斯模糊降噪";
        stepDealStart(current, total, stepName);
        final Bitmap blurBitmap = createBitmapAsSrc(srcBitmap);
        Mat blurMat = new Mat();
        try {
            Imgproc.GaussianBlur(preMat, blurMat, new Size(3, 3), 0);
            Utils.matToBitmap(blurMat, blurBitmap);
            stepDealComplete(current, total, stepName, blurBitmap, null, true);
        } catch (Exception e) {
            blurMat.release();
            stepDealComplete(current, current, stepName + "失败:" + e.getMessage(), null, null, false);
        }
        return blurMat;
    }

    @NonNull
    private Mat doGray(Bitmap srcBitmap, Mat preMat, final int current, final int total) {
        final String stepName = "灰度化";
        stepDealStart(current, total, stepName);
        final Bitmap grayBitmap = createBitmapAsSrc(srcBitmap);
        Mat grayMat = new Mat();
        try {
            Imgproc.cvtColor(preMat, grayMat, Imgproc.COLOR_BGRA2GRAY);
            Utils.matToBitmap(grayMat, grayBitmap);
            stepDealComplete(current, total, stepName, grayBitmap, null, true);
        } catch (Exception e) {
            grayMat.release();
            stepDealComplete(current, current, stepName + "失败:" + e.getMessage(), null, null, false);
        }
        return grayMat;
    }

    private void calcReferenceAxis(Mat measureMat, AnswerSheetModel answerSheet) {
        //绘制横向直线
        Imgproc.line(measureMat, new Point(0, answerSheet.offsetTop), new Point(answerSheet.width, answerSheet.offsetTop), getLineColorWhite());
        Imgproc.line(measureMat, new Point(0, answerSheet.height - answerSheet.offsetBottom), new Point(answerSheet.width, answerSheet.height - answerSheet.offsetBottom), getLineColorWhite());
        for (int i = 1; i <= AnswerSheetConfig.TOTAL_ROW_COUNT; i++) {
            int preEmptyRowCount = i / AnswerSheetConfig.PER_ROW_COUNT;
            float marginTop = answerSheet.offsetTop + i * answerSheet.answerHeight + preEmptyRowCount * answerSheet.emptyRowHeight;
            //最后一条线不需要在循环中绘制,之前已经绘制过
            if (i < AnswerSheetConfig.TOTAL_ROW_COUNT) {
                if (i % AnswerSheetConfig.PER_ROW_COUNT == 0) {
                    Imgproc.line(measureMat, new Point(0, marginTop - answerSheet.emptyRowHeight), new Point(answerSheet.width, marginTop - answerSheet.emptyRowHeight), getLineColorWhite());
                }
                Imgproc.line(measureMat, new Point(0, marginTop), new Point(answerSheet.width, marginTop), getLineColorWhite());
            }
        }
        //绘制纵向直线
        Imgproc.line(measureMat, new Point(answerSheet.offsetLeft, 0), new Point(answerSheet.offsetLeft, answerSheet.height), getLineColorWhite());
        Imgproc.line(measureMat, new Point(answerSheet.width - answerSheet.offsetRight, 0), new Point(answerSheet.width - answerSheet.offsetRight, answerSheet.height), getLineColorWhite());
        for (int i = 1; i <= AnswerSheetConfig.TOTAL_COL_COUNT; i++) {
            int preEmptyColCount = i / AnswerSheetConfig.PER_COL_COUNT;
            float marginLeft = answerSheet.offsetLeft + i * answerSheet.answerWidth + preEmptyColCount * answerSheet.emptyColWidth;

            //最后一条线不需要在循环中绘制,之前已经绘制过
            if (i < AnswerSheetConfig.TOTAL_COL_COUNT) {
                if (i % AnswerSheetConfig.PER_COL_COUNT == 0) {
                    Imgproc.line(measureMat, new Point(marginLeft - answerSheet.emptyColWidth, 0), new Point(marginLeft - answerSheet.emptyColWidth, answerSheet.height), getLineColorWhite());
                }
                Imgproc.line(measureMat, new Point(marginLeft, 0), new Point(marginLeft, answerSheet.height), getLineColorWhite());
            }
        }
    }

只看一下 dealWithPhoto(final File file)方法,内部做了图片灰度化、模糊降噪、二值化、绘制参考线和识别填涂位置的操作。
1.灰度化,就是将一幅彩色图片,变成灰阶图像(什么是灰阶图像?嗯,80年代的黑白电视);
2.模糊降噪,就是将一副图片进行模糊(比如采用高斯模糊算法),最终去除某些噪点;
3.二值化,就是将上述的灰阶图像转化为一幅仅含有纯黑和纯白的图像。比如一幅灰阶图像有4个灰阶:纯黑、深灰、浅灰、白色,那么将纯黑和深灰都处理为纯黑,将浅灰和白色都处理为白色,得到的新图像仅有纯黑和纯白两种颜色,这就是二值化;
4.绘制参考线和识别填涂位置,涉及到了OpenCV图像绘制和图像的基本运算。

如果你是第一次接触OpenCV的图像处理,有点看不懂?没关系!这只是一篇引子,未来两行哥还会继续为大家介绍OpenCV的图像处理,从最基础的开始,让我们一起研究,这只是学习的起点。再会。

本项目已上传至码云:AnswerSheetScanDemo。

你可能感兴趣的:(〔两行哥〕OpenCV4Android入门教程之API系列(一))