Logo
Android Kotlin Development Guide

Image Similarity Detection & Grouping via pHash

Implement Perceptual Hashing (pHash) in Kotlin to analyze local image contents, group similar or duplicate photos, and efficiently render them using RecyclerView and Coil.

Step 1: Project Structure & Dependencies

Organize your architecture and add Coil for image loading

Package Directory

app/src/main/java/com/wefamily/papa/ui/
├── similarity/
│   ├── ImageSimilarityActivity.kt # 视图入口
│   ├── SimilarityAdapter.kt       # RecyclerView 适配器
│   ├── ImageHashUtils.kt          # pHash 核心算法
│   └── SimilarityGroup.kt         # 数据实体类
└── res/
    └── layout/
        ├── activity_similarity.xml
        └── item_similarity_group.xml

build.gradle.kts

Add Coroutines for background hashing and Coil for fast image rendering.

dependencies {
    // Kotlin Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
    
    // Coil Image Loader (Fast, lightweight)
    implementation("io.coil-kt:coil:2.5.0")
    
    // RecyclerView
    implementation("androidx.recyclerview:recyclerview:1.3.2")
}

Step 2: Perceptual Hash (pHash) Algorithm

Generate 64-bit hash fingerprints and compute Hamming distance

ImageHashUtils.kt
package com.wefamily.papa.ui.similarity

import android.graphics.Bitmap
import android.graphics.Color

object ImageHashUtils {
    // 1. 计算图像的 aHash (Average Hash) 核心逻辑
    fun calculateHash(bitmap: Bitmap): Long {
        // 将图片缩小到 8x8,忽略细节,只保留明暗基本特征
        val scaledBitmap = Bitmap.createScaledBitmap(bitmap, 8, 8, false)
        val pixels = IntArray(64)
        scaledBitmap.getPixels(pixels, 0, 8, 0, 0, 8, 8)

        // 转化为灰度并计算平均灰度值
        var totalGray = 0
        val grayPixels = IntArray(64)
        for (i in pixels.indices) {
            val pixel = pixels[i]
            val gray = (Color.red(pixel) * 30 + Color.green(pixel) * 59 + Color.blue(pixel) * 11) / 100
            grayPixels[i] = gray
            totalGray += gray
        }
        val avgGray = totalGray / 64

        // 生成 64 位整数哈希,像素大于平均值记作 1,否则为 0
        var hash = 0L
        for (i in 0 until 64) {
            if (grayPixels[i] >= avgGray) {
                hash = hash or (1L shl i)
            }
        }
        scaledBitmap.recycle()
        return hash
    }

    // 2. 计算两个哈希值之间的汉明距离 (差异位数)
    fun getHammingDistance(hash1: Long, hash2: Long): Int {
        return java.lang.Long.bitCount(hash1 xor hash2)
    }
}
Distance Threshold Note: A Hamming Distance of 0 means identical images. A distance ≤ 5 usually indicates very similar images (e.g., burst mode shots). > 10 means different contents.

Step 3: Background Processing & UI Injection

Load bitmaps in Dispatchers.Default and group by threshold

ImageSimilarityActivity.kt
package com.wefamily.papa.ui.similarity

// ... 导入省略 ...
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

data class SimilarityGroup(val mainImageUri: String, val similarImages: MutableList<String> = mutableListOf())

class ImageSimilarityActivity : AppCompatActivity() {
    private lateinit var adapter: SimilarityAdapter

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

        adapter = SimilarityAdapter()
        findViewById<RecyclerView>(R.id.recyclerView).adapter = adapter

        // 模拟从本地获取的相册 URI 列表,开始检测
        val imageUris = listOf("file:///sdcard/img1.jpg", "file:///sdcard/img2.jpg" /* ... */)
        processImages(imageUris)
    }

    private fun processImages(paths: List<String>) {
        lifecycleScope.launch(Dispatchers.Default) {
            val hashMap = mutableMapOf<String, Long>()
            // 1. 计算所有图片的 Hash
            for (path in paths) {
                val bitmap = BitmapFactory.decodeFile(path.replace("file://", ""))
                if (bitmap != null) {
                    hashMap[path] = ImageHashUtils.calculateHash(bitmap)
                }
            }

            // 2. 分组逻辑 (距离 <= 5 视为相似)
            val groups = mutableListOf<SimilarityGroup>()
            val processed = mutableSetOf<String>()

            for ((path1, hash1) in hashMap) {
                if (processed.contains(path1)) continue
                val group = SimilarityGroup(path1)
                processed.add(path1)

                for ((path2, hash2) in hashMap) {
                    if (!processed.contains(path2)) {
                        val distance = ImageHashUtils.getHammingDistance(hash1, hash2)
                        if (distance <= 5) {
                            group.similarImages.add(path2)
                            processed.add(path2)
                        }
                    }
                }
                if (group.similarImages.isNotEmpty()) {
                    groups.add(group) // 仅展示有重复/相似内容的组
                }
            }

            // 3. 主线程刷新 UI
            withContext(Dispatchers.Main) {
                adapter.submitList(groups)
            }
        }
    }
}

Step 4: UI Rendering with Coil & Adapter

Display the parent image and a nested row of similar counterparts

SimilarityAdapter.kt
package com.wefamily.papa.ui.similarity

import coil.load
// 配合您的 com.wefamily.papa 项目,此处展示用 Coil 进行快速图片绑定

class SimilarityAdapter : ListAdapter<SimilarityGroup, SimilarityAdapter.ViewHolder>(DiffCallback) {

    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val ivMain: ImageView = view.findViewById(R.id.ivMainGroupImage)
        val tvCount: TextView = view.findViewById(R.id.tvSimilarCount)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)
        // 利用 Coil 极简加载本地图片
        holder.ivMain.load(item.mainImageUri) {
            crossfade(true)
            size(ViewSizeResolver(holder.ivMain)) // 优化内存分配
        }
        holder.tvCount.text = "发现 \${item.similarImages.size} 张相似图片"
        // 若需展示所有子图片,可在此处嵌套一个横向的 RecyclerView 或直接添加 View
    }

    companion object {
        private val DiffCallback = object : DiffUtil.ItemCallback<SimilarityGroup>() {
            override fun areItemsTheSame(old: SimilarityGroup, new: SimilarityGroup) = old.mainImageUri == new.mainImageUri
            override fun areContentsTheSame(old: SimilarityGroup, new: SimilarityGroup) = old == new
        }
    }
}

Explore More#mobile-dev

Integrating FFmpeg 7+ with Kotlin via JNI

Article

A complete, step-by-step visual guide to integrating pre-compiled FFmpeg 7 dynamic libraries (.so) into a modern Android project using CMake, JNI, and Jetpack Compose.

Android FFmpeg + C++: Video Cropping and Filter Processing

Article

A detailed demonstration of how to use FFmpeg and C++ in an Android project to implement video cropping, filter processing, and progress callbacks.

Integrating OpenCV 4.x in Android Studio

Article

A detailed step-by-step guide on how to integrate OpenCV 4.x into an Android Studio project, configure CMake and JNI, and implement image processing features.

Using OpenCV for Image Sharpening in Android

Article

A detailed step-by-step guide on how to integrate OpenCV into an Android project and implement image sharpening functionality.

Android OpenCV + FFmpeg 7: Making Videos Clearer from Blurry

Article

A detailed demonstration of how to use OpenCV and FFmpeg 7 in an Android project for video processing, achieving clarity from blurriness.

FFmpeg Android + OpenSSL Compilation Guide

Article

A detailed step-by-step guide on how to compile FFmpeg from source and integrate OpenSSL on the Android platform for HTTPS streaming support.

FFmpeg Xcode + Swift Compilation Guide

Article

A detailed step-by-step guide on how to compile FFmpeg from source using Xcode and Swift on macOS, and integrate it into an iOS project.

Android Image Similarity Detection and Grouping

Article

A detailed demonstration of how to use OpenCV in an Android project to implement image similarity detection and grouping features.