前言
书接上回,现在越来越多的手机支持了 AI 功能,AI 搜图变成了逐渐变成了很多手机的标配。上一篇文章介绍了,AI 搜图的的原理及开发,如果有感兴趣的可以通过传送门了解一下。
本篇文章会介绍一种本人设计
的使用 AI 来高效查找重复图片
的算法,并且最后会给出代码,方便大家体验,当然也欢迎大家给出反馈。
原理介绍
特征提取与相似度计算
我们通过Clip模型提取图片的特征向量(一个512维的浮点数组),并通过余弦相似度计算向量间相似性。与欧氏距离不同,余弦相似度关注向量方向而非长度,更适合语义特征比对。
特征向量如何变成相似度
fun calculateSimilarity(vectorA: FloatArray, vectorB: FloatArray): Double {
var dotProduct = 0.0
var normA = 0.0
var normB = 0.0
for (i in vectorA.indices) {
dotProduct += vectorA[i] * vectorB[i]
normA += vectorA[i] * vectorA[i]
normB += vectorB[i] * vectorB[i]
}
normA = sqrt(normA)
normB = sqrt(normB)
return dotProduct / (normA * normB)
}
本代码库使用 余弦相似度
来计算图片的相似情况。
余弦相似度的结果说明
所谓的余弦相似度即是:求两个特征向量之间的夹角,角度越小,越趋近于 0°,说明越接近,那么结果越接近于 1。
他跟欧拉距离是不同的表达方式,如果使用欧拉距离计算的是距离越小两者之间越相似。
分析图中向量特征
推论
假若两个向量越相似,那么他们的方向夹角便会越小。从图上看,假设有一个蓝色向量 (1,1) 作为基础向量,如果两个向量非常接近(角度非常小)那么他们跟对照向量的夹角偏差便很小。
换个通俗意义来讲就是,如果向量 A,B 很相似,那么 A,B 与对照向量 C 的相似度也应该很高。换成我们知道就是如果两个图片相似度极高,那么他们与一个对照图片的相似度应该是非常接近的。
我们可不可以利用这一点,从数据库里面找出任意一张图的相似的图片呢?利用上面的推论我们可能会对相册中的每个图片与一个对照图片(向量)进行计算相似度,然后保存到数据库之中,这一步我称之为预处理
。然后我们遍历图片,使用图片的相似度查找与之相似的向量。
一个隐藏的问题
之前我们的推论是两两相似那么他们定然与对照向量的相似,反之如果如果他们与对照组的相似度一致那么他们一定相似吗。我们不妨看下上面的示意图,(0,1) 向量与对照向量(1,1)的相似度(夹角)与(1,0)与(1,1) 的相似度一样。可见反之是不成立的,不过与对照组相似度一致的会包含,相似的图片,而且其中可能会存在多组的相似的图片,或者无关图片。如何高效筛选并分组真正的相似图片?
巧用数据结构解决问题
分组算法设计
- 预处理:计算所有图片与对照向量(白色图片)的相似度并存储。
- 为何选择白色? 测试表明,白色能均匀覆盖RGB色彩空间,减少偏差。
- 粗筛:通过TreeSet快速找到相似度接近的候选图片(耗时仅0.07ms/次)。
- 精筛:对候选图片两两计算相似度,构建图结构并通过并查集(Union Find)合并连通分量,最终输出分组。
Union Find 是一种常见的用于高效合并分组的数据结构
与对照向量相似度相近的结果可能示意图:
可以看到,假设查找出来的结果有 9 个向量,其中可能 1,2,3,4 他们的比较相似,划分成一组,然后 5,6,7 可能类似划分成一组,由于 8,9 并没有与其他向量类似,那么在构建图的时候便不把他们计算在内,因为他们不予任何向量类似,不满足相似图片的定义。
时间复杂度分析
对于查找所有的相似的图片,时间复杂度取决于图片相似度的组数,最坏的你情况 为 N²,实际使用中由于相似的图片很少,查找的复杂度会大幅度下降。此算法假设相似图片组数量较少(<100组),若用户存在大量相似图片(如连拍照片),建议设置时间阈值或分批次处理。
代码实现
预处理
选取对照组向量
我在实践过程选取的是纯白色的一张图片来作为对照图片,由于白色是 rgb 组成的 fff,特征比较多,但是我并不能确定白色作为对照向量是否是最佳,但是实际测试中,白色图片作为对照向量时,相似度分布更均匀,误判率低于随机图片。
计算并保存结果
遍历数据库里面的已经 index 的图片,逐个与对照向量进行计算,然后保存结果到数据库
具体实现
设计一个 Worker,对没有计算相似度的向量进行计算相似度,在这里推荐大家使用 worker 来做一些不是很紧急的工作,他的配置更多,策略更丰富。 doWork 代码
embeddingRepository.getAllEmbeddingsPaginated(pageSize)
.flatMapMerge { embeddingsPage ->
flow {
val similarities = embeddingsPage
.filterNot { calculatedSet.contains(it.photoId) }
.map { comparedEmbedding ->
val similarityScore = calculateSimilarity(
baseline,
comparedEmbedding.data.toFloatArray()
)
ImageSimilarity(
basePhotoId = comparedEmbedding.photoId,
comparedPhotoId = -1,
similarityScore = similarityScore.toFloat()
)
}
emit(similarities)
}
}
.collect { similarities ->
if (similarities.isNotEmpty()) {
imageSimilarityDao.insertAll(similarities)
}
}
查看相似图片
我们这里把查找相似的图片的功能封装在一个叫做 GroupSimilarPhotosUseCase
的功能类之中,这个类的主要功能是接收一组带有相似度的节点,返回其中有包含相似度图片的组。
执行过程
- 预处理
首先把所有的相似度添加进一个 TreeSet 之中,由于 TreeeSet 本身是红黑树,存储是有序的,查找效率是 logN 的,我们可以充分利用这一点,在极短的时间内找出相应的复杂度。 - 逐个查找相似的相似的图片
使用 for 循环,在 TreeSet 之中逐个查找相似度接近的图片,并且返回相似接近的数组 - 对返回的结果构建图,对查找出来的结果进行两两比对相似度
- 方法 1 使用
并查集
构建联通图 - 方法 2 使用
邻接表
的方式来构建图
- 方法 1 使用
- 查找联通分量 使用 dfs/unionfind 查找图中的联通分量,同一个联通分量的便是一组相似的图片
- 使用
SimilarityManager
简化使用,并且缓存搜索结果到内存
查看相似图片页
- 定义页面 UI state
sealed interface SimilarPhotosUiState {
data object Loading : SimilarPhotosUiState
data class Success(val similarPhotoGroups: List<List<Photo>>) : SimilarPhotosUiState
data object Empty : SimilarPhotosUiState
}
private val _uiState = MutableStateFlow<SimilarPhotosUiState>(SimilarPhotosUiState.Loading)
val uiState: StateFlow<SimilarPhotosUiState> = _uiState
- 接收数据并更新
similarityGrouper.groupSimilarPhotos()
.catch { e ->
Log.e("SimilarPhotosViewModel", "Error loading similar photos", e)
_uiState.value = SimilarPhotosUiState.Empty
}
.collect{ similarGroup ->
Log.d("SimilarPhotosViewModel", "Similar group: $similarGroup")
val photoGroup = similarGroup.mapNotNull { node ->
photoRepository.getPhotoById(node.photoId)
}
if (photoGroup.isNotEmpty()) {
similarGroups.add(photoGroup)
_uiState.update { SimilarPhotosUiState.Success(similarGroups.toList()) }
}
}
Benchmarks
评估使用 clip 模型对不同 transform 对相似度的影响
下图是我在小米 14 pro 手机相册挑选十张不同图像,分别经过不同 transform 过程然后使用 Clip int8 模型计算特征向量得到的结果的平均值。
总体分析:
- 图像特征对尺寸和旋转最敏感
- 轻微变换(小尺寸、小角度、轻微模糊)影响较小
- 极端变换(大角度旋转、极低分辨率)会显著降低相似度
从上面的结果我们也可以看出来 Clip 模型的评估的整体方案是可行的,符合我们日常对相似度的理解。
搜索用时
TreeSet 查询相似度接近向量耗时
平均查询耗时: 0.07 ms,由于TreeSet 本身使用了红黑树来查找,查找效率很高,这一步的耗时几乎可以忽略不计
构建图
构建图查找这块,取决于图中顶点的数量,耗时从 1000ms 到 0ms 都有,是耗时的大户,重点优化的方向。
当然对于单次查找来说 1-2 S 的加载(等待)时间是完全 OK 的,但是对于全局查找来说最好还是在执行一些预处理的策略,保证用户的最少等待时间。由于不同组的相似图互不干扰,所以可以使用 async 来优化建图的过程。
相似度计算
对相似度计算,单个搜索时间在 1ms 以内,但是由于图片本身数量很大,如果一个 group 里面图片很多,时间会累积很多。具体时间复杂度应该是 group 内部元素size=n, 最终计算数值可能有好几万次,数量还是很大的,千万不能小看了这个时间。
在相似度计算的时候,使用了 float 来计算,而不是 double 尽量减少内存占用。
配置参数
设置两个参数用于控制搜索过程的阈值,还有一个可以设置的 config 页面,方便开发者自行调整。
- searchImageSimilarityThreshold 用于设置图像相似度的下限
- similarityGroupDelta 设置初筛阈值,用于比对初筛图像与基准向量的偏差值
小米 14 Pro、Android 14 上预处理 1520 张图片之后,配置参数 searchImageSimilarityThreshold=0.99,similarityGroupDelta=0.03
搜索全部相似图片,结果共计 18 组,
- 使用 UnionFind 串行搜索约耗时 4100ms左右,使用 async 优化之后 2100ms 左右
- 使用 DFS 查找联通分量约耗时 5800ms
Note:实际使用过程建议 similarityGroupDelta
参数不宜设置过大,最好不要超过 0.05
,他对整个流程影响较大,searchImageSimilarityThreshold
参数可以设置在 0.95
以上,具体要看不同的业务场景
源码地址
代码地址详见 PicQuery,点击相似图片即可进入体验。
后期计划
当前的处理速度说实话,是完全可以实现线上应用的,时间消耗也可以。不过后期还是希望能够继续优化时间复杂度。希望能够添加更多的配置,可以选择保存结果,或是添加异步处理图片分组,使用事件来通知 UI 层来刷新新的图片,尽量减少用户感知。