外挂三部曲(三) —— Android 图片相似度对比

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

上篇文章我们一起学习了如何截取屏幕,本文介绍如何对比图片相似度,这个功能可以帮助我们将截屏结果与预存的一些图片进行对比,以了解当前处于哪个屏幕。

再通过第一篇文章介绍的辅助点击,就可以给手机整活了。

一、第一种对比方式

第一种对比方式是:取出两张 bitmap 中的所有像素,然后一一进行对比。匹配的点除以总点数就能得到一个相似度。代码如下:

object SimilarityUtils {
    fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {
        // 获取图片所有的像素
        val pixels1 = getPixels(bitmap1)
        val pixels2 = getPixels(bitmap2)
        // 总的像素点数以较大图片为准
        val totalCount = pixels1.size.coerceAtLeast(pixels2.size)
        if (totalCount == 0) return 0.00
        var matchCount = 0
        var i = 0
        while (i < pixels1.size && i < pixels2.size) {
            if (pixels1[i] == pixels2[i]) {
                // 统计相同的像素点数量
                matchCount++
            }
            i++
        }
        // 相同的像素点数量除以总的像素点数,得到相似比例。
        return String.format("%.2f", matchCount.toDouble() / totalCount).toDouble()
    }

    private fun getPixels(bitmap: Bitmap): IntArray {
        val pixels = IntArray(bitmap.width * bitmap.height)
        // 获取每个像素的 RGB 值
        bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
        return pixels
    }
}

可以看到,similarity 函数接收两个 Bitmap,返回一个 Double 值,这个值的取值范围是 0.00~1.00,表示相似度。

首先通过 bitmap.getPixels 取出所有的像素点,以其中较多的像素点作为总点数。

然后通过 pixels1[i] == pixels2[i] 对比每个像素点,如果相同则 matchCount 加一,最后用 matchCount / totalCount 计算出相似度。

这种比较方式特别直观,容易理解,通过每个像素点依次比较得出相似度。我们也很容易想到它的缺点:如果第二张图片是由第一张图片缩放、变形、旋转等变换得来的,那么每个像素点可能都无法匹配上,所以相似度会很低很低。

也就是说,这个算法几乎只能用于比较图片是否一模一样,只要两张图的像素点有细微的错位,比较结果就会完全不准确。

不过其实这种算法已经能够满足我们的需求了,只要我们每次都取一样的 Bitmap 进行比较就可以了。只要保证整张图都一样,或者从 Bitmap 裁剪出的固定区域一样就可以了。此时比较结果可以供我们正常使用。

但更好的做法是通过 SIFT 算法计算相似度。

二、通过 SIFT 算法计算相似度

SIFT 算法指的是尺度不变特征转换 (Scale Invariant Feature Transform)。它是计算机视觉领域中描述图片特征的一种算法,应用非常广泛。

这个算法是由一些大神们研究出来的,由于本文不是在写论文,所以我也不会对这个算法进行深究,简单介绍一下它的大概原理:

先将图片映射为空间中的坐标:

location

再从所有坐标中过滤出其中的特征点:

filter

再为特征点分配一个方向值,使得图片变形后仍然能够正确匹配:

direction

将这些信息转换成数学描述:

magic math

注:算法原理的这段内容,只是我个人一点粗浅的理解,可能和算法的实际实现有出入。但这个算法的实现不是本文的重点,重点在于这个算法可以用于对比两张图片的相似度。所以于我而言,我愿将其称之为魔法。

这个算法被封装在 OpenCV 库中,所以使用前需要导入 OpenCV 库。

OpenCV 官方没有提供 gradle 导入的方式,所以网上有许多导入 OpenCV 库的教程,讲的都是去下载 OpenCV 的源码,再通过 Module 的方式加入项目中。

但国外有民间大佬为我们封装了 gradle 导入的方式,大佬封装的 github 地址:github.com/quickbirdst…

所以现在我们可以直接在 build.gradle 中直接导入 OpenCV 库:

implementation 'com.quickbirdstudios:opencv:4.5.3.0'

需要注意的是,OpenCV 库非常大,导入这个库会让 apk 的体积增加 100 多 M,所以要慎用。

有了 OpenCV 库,就可以编写图片相似度对比工具类了:

object SIFTUtils {
    // SIFT detector
    private val siftDetector by lazy { SIFT.create() }

    fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {
        // 计算每张图片的特征点
        val descriptors1 = computeDescriptors(bitmap1)
        val descriptors2 = computeDescriptors(bitmap2)
        // 比较两张图片的特征点
        val descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.FLANNBASED)
        val matches: List<MatOfDMatch> = ArrayList()
        // 计算大图中包含多少小图的特征点。
        // 如果计算小图中包含多少大图的特征点,结果会不准确。
        // 比如:若小图中的 50 个点都包含在大图中的 100 个特征点中,则计算出的相似度为 100%,显然不符合我们的预期
        if (bitmap1.byteCount > bitmap2.byteCount) {
            descriptorMatcher.knnMatch(descriptors1, descriptors2, matches, 2)
        } else {
            descriptorMatcher.knnMatch(descriptors2, descriptors1, matches, 2)
        }
        Log.i("~~~", "matches.size: ${matches.size}")
        if (matches.isEmpty()) return 0.00
        // 获取匹配的特征点数量
        var matchCount = 0
        // 邻近距离阀值,这里设置为 0.7,该值可自行调整
        val nndrRatio = 0.7f
        matches.forEach { match ->
            val array = match.toArray()
            // 用邻近距离比值法(NNDR)计算匹配点数
            if (array[0].distance <= array[1].distance * nndrRatio) {
                matchCount++
            }
        }
        Log.i("~~~", "matchCount: $matchCount")
        return String.format("%.2f", matchCount.toDouble() / matches.size).toDouble()
    }

    private fun computeDescriptors(bitmap: Bitmap): MatOfKeyPoint {
        val mat = Mat()
        Utils.bitmapToMat(bitmap, mat)
        val keyPoints = MatOfKeyPoint()
        siftDetector.detect(mat, keyPoints)
        val descriptors = MatOfKeyPoint()
        // 计算图片的特征点
        siftDetector.compute(mat, keyPoints, descriptors)
        return descriptors
    }
}

在这个类中,同样有一个 similarity 方法,接收两个 Bitmap,返回一个 0.00~1.00 的 Double 型数据,表示图片的相似度。

首先通过 SIFT.create() 构建出用 SIFT 算法实现的图片检测器 siftDetector,再通过 siftDetector.compute 计算出图片的特征点。

再通过 DescriptorMatcher.create 构建出 descriptorMatcher 对象,通过 descriptorMatcher.knnMatch 方法比较出两张图片相似的特征点数量。

这里比较时有一个 if 条件判断,它的作用是保证比较的是大图中包含多少小图中的特征点。因为如果计算小图中包含多少大图的特征点,结果会不准确。

比如:若小图中的 50 个点都包含在大图中的 100 个特征点中,则计算出的相似度为 100%,显然不符合我们的预期。

最后通过 array[0].distance <= array[1].distance * nndrRatio 判断特征点是否相似,统计出相似的特征点数量后,通过 matchCount / matches.size 计算出相似度。

三、测试

先在 res/drawable 文件夹下放一张图片,比如我放了一张我的头像,命名为 img.png:

img.png

然后修改 MainActivity 中的代码:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)
        val bitmap2 = Bitmap.createBitmap(bitmap1, 0, 0, bitmap1.width / 2, bitmap1.height / 2)
        Log.d("~~~", "similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")
    }
}

首先通过 BitmapFactory.decodeResource 将 res/drawable 文件夹中的图片取出来,转换成 Bitmap,构建出 bitmap1。

bitmap2 由 bitmap1 裁剪而来,通过 Bitmap.createBitmap 方法,从 bitmap1 的 (0, 0) 位置开始,裁剪出宽为原图一半、高为原图一半的 Bitmap。

然后调用 SIFTUtils.similarity(bitmap1, bitmap2) 比较两张图片的相似度。

非常完美!

运行代码,立马 crash:

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.imagesimilarity, PID: 21924
    java.lang.UnsatisfiedLinkError: No implementation found for long org.opencv.core.Mat.n_Mat() (tried Java_org_opencv_core_Mat_n_1Mat and Java_org_opencv_core_Mat_n_1Mat__)
        at org.opencv.core.Mat.n_Mat(Native Method)
        at org.opencv.core.Mat.<init>(Mat.java:23)
        at com.example.imagesimilarity.SIFTUtils.computeDescriptors(SIFTUtils.kt:50)
        at com.example.imagesimilarity.SIFTUtils.similarity(SIFTUtils.kt:19)
        at com.example.imagesimilarity.MainActivity.onCreate(MainActivity.kt:38)
        at android.app.Activity.performCreate(Activity.java:8000)

果然凡事都没有一帆风顺的。这个报错大致意思是没有找到 OpenCV 中的某个方法的具体实现。奇了怪了,我们明明已经导入过 OpenCV 库了。

查询一番后,在 StackOverflow 上找到了答案,原因是 OpenCV 使用前需要先初始化。

MainActivity 代码修改如下:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val loaded = OpenCVLoader.initDebug()
        Log.d("~~~", "loaded: $loaded")
        if (loaded) {
            val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)
            val bitmap2 = Bitmap.createBitmap(bitmap1, 0, 0, bitmap1.width / 2, bitmap1.height / 2)
            Log.d("~~~", "similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")
        }
    }
}

在 onCreate 方法中,先调用 OpenCVLoader.initDebug 方法初始化 OpenCV,通过其返回值判断是否加载成功,当加载成功后再执行我们刚才的比较相似度逻辑。

运行程序,Logcat 控制台输出如下:

D/~~~: loaded: true
I/~~~: matches.size: 190
I/~~~: matchCount: 88
D/~~~: similarity: 0.46

表示两张图片的相似度为 46%,说明我们的程序已经正常工作了。

四、后记

到这里,我们的外挂三部曲系列就完结了。这三篇文章讲述了三个独立的技术点:模拟点击、应用外截屏、图像识别。这些技术对用户而言有些风险,所以通常都需要用户手动授权。比如模拟点击前需要用户开启辅助功能,截取屏幕前需要用户同意应用读取屏幕。

为什么没有讲他们的综合运用呢?这实际上是我无奈之举。这些技术像是黑魔法,有些黑科技成分,不便细讲,我平时也只运用在自己的个人手机上,让它们帮我做一些机械的重复工作。这几篇文章只是给大家介绍锤子、钉子、板子,如何用它们制作桌椅板凳还需要读者亲自动手。

五、参考文章

android中比较两张图片的相似度

Android 图片相似度比较-SIFT&直方图

百度百科: SIFT

github: opencv-android

No implementation found for long org.opencv.core.Mat.n_Mat() error Using OpenCV

继续阅读:

外挂三部曲(一) —— Android 7.0 以上,使用辅助功能模拟点击对应坐标

外挂三部曲(二) —— Android 应用外截屏

猜你喜欢

转载自juejin.im/post/7113578494864408612
今日推荐