
简介面向 iOS 开发与计算机视觉学习者这份工程资源围绕“快速筛选相册相似图片”这一需求基于 OpenCV 图像比较能力实现了一个可运行的 Xcode 示例项目适配毕业设计、课程设计等场景。压缩包内含 57 个文件以 Objective-C 源码h/m为主配以 JSON、plist 配置和 storyboard 界面文件以及 Xcode 工程与 Podfile 等集成配置整体仅 56KB结构紧凑、便于直接查看核心逻辑。目前已有 153 人学习下载。项目目录划分明确包含 AppDelegate/SceneDelegate 生命周期管理、主界面与测试控制器、AMImageCompareCore 图像比较核心模块及集合视图展示部分读者可据此掌握 OpenCV 在 iOS 工程中的接入方式、相似度比较的流程设计与界面联动思路是一份轻量但完整度较高的参考实现。1. 基于 OpenCV 筛选 iOS 相册相似图片把“人眼对比”换成“感知哈希”相信很多人和我一样手机里存了上万张照片一半是重复截图和连拍。真到找图时翻到手酸还白白占 iCloud 空间。这个标题里的方案本质就是拿 OpenCV 给每张照片算一个短指纹再两两比较指纹把汉明距离很小的一批图挑出来。它不做物体识别不搞智能分类只回答“哪些图长得像”让清理照片从人眼一小时缩短到脚本几分钟。适合相册上千张、定期想清理重复图、又不想把照片传到任何云端服务的 iOS 用户也适合刚开始接触 OpenCV 图像处理、想找一个能立刻跑通的项目练手的人。2. 相似图片判定原理感知哈希、直方图与特征点的取舍2.1 感知哈希把图片压缩成指纹汉明距离衡量相似度感知哈希的思路很直白先把图片缩放到统一尺寸转成灰度把像素亮度关系变成一串二进制位。这张二进制串就是指纹。两张图越像指纹里不同的位数就越少。OpenCV 里没有专门的“感知哈希”接口但实现起来只需要 resize、cvtColor 和几个 NumPy 操作不需要额外依赖。常见变体有三种aHash 比较像素与平均值的相对大小pHash 用离散余弦变换的低频分量dHash 比较相邻像素的亮度变化。三者里dHash 在“同一场景连续拍了几张”这类任务上最稳因为它对光照变化和轻微平移不敏感。pHash 对缩放、旋转更宽容但计算量稍大。aHash 实现最简单误报也最高我一般只在跑通流程时用它。哈希位数决定了区分粒度。常见做法是 resize 成 8x9得到一个 64 bit 指纹。64 bit 里相差少于 8 到 10 位基本可以认为两张图是肉眼相似的。这个阈值不是拍脑袋定的跟你相册里照片类型强相关第 4 章我会专门讲如何标定。2.2 直方图与特征点匹配适合局部裁剪和旋转场景感知哈希擅长处理“整体相似”的图但遇到裁剪掉三分之一、旋转 90 度或者加了大量滤镜的情况哈希输出会完全变掉。这时候可以考虑直方图匹配计算两张图的颜色分布再用相关性或卡方距离比较。OpenCV 的 calcHist 和 compareHist 可以一步到位。缺点也很明显它只关心颜色统计不关心空间布局两张都是蓝天大海的图会被判断为高度相似。如果目标是识别“同一张图的不同尺寸”“截图再裁掉一块”“加了滤镜但内容不变”特征点匹配是更可靠的方案。OpenCV 里常见流程是 ORB 检测关键点计算描述子再用 BFMatcher 匹配最后看匹配点数占特征点总数的比例。特征点匹配对旋转、缩放、裁剪都比较鲁棒但计算量比哈希高一个量级上万张图两两匹配基本跑不动。我的固定做法是先用哈希把可疑的几百对筛出来再用特征点做精排而不是一上来就对全库做特征点匹配。2.3 三种算法选型对比先明白你要筛掉的是哪种“相似”拿到代码前先想清楚一个前提你眼里的“相似”是哪一种是 iPhone 连拍里几乎一样的几张是同一个文件复制了两遍还是同一张图裁掉边框后的版本这三种诉求对应算法和阈值完全不同。场景推荐算法速度说明完全相同的图、截图重复dHash 汉明距离最快误报少适合首批清理连拍、同角度多张pHash较快对微小差异容忍度更高裁剪、旋转、加滤镜ORB 特征点匹配慢只适合做二次精排颜色相似但内容不同直方图匹配中等误报率高不推荐做主筛选选型原则优先用 dHash 做一轮粗筛控制召回率必要的时候再上特征点精排。OpenCV 在这里之所以够用是因为它把图片读取、缩放、灰度化、特征提取这些底层能力都做好了你不需要另起炉灶写一个图像解码模块只需要专注在上层逻辑。2.4 为什么选 OpenCV而不是自写或用神经网络有人会问直接调一个图像分类模型来判断相似不就行了对相册去重这个场景深度学习模型的成本远高于收益。模型需要跑推理需要显存或大内存而且输出是“物体类别”不是“两张图是否相似”。你要的只是像素层面的重复关系这恰恰是经典图像处理最擅长的问题。OpenCV 的另一大优势是可解释、可调试。某两张图被误判你可以打印指纹、看汉明距离、调阈值每一步都有明确中间结果。换成模型一旦输出不对你只能怀疑训练数据。对非专业 AI 从业者来说OpenCV 这条路的确定性更高代码量也更少。3. 搭建本地筛选脚本从 iOS 相册导出照片到相似分组输出3.1 先把 iOS 相册照片原样导出保留文件名和拍摄时间动手写 OpenCV 代码之前第一步是把照片从 iPhone 里倒出来。常见做法是用数据线连接电脑在系统的“照片”App 里选择导入或者直接拷贝 DCIM 目录。有一个关键点不要用微信或 AirDrop 再传一遍因为经过这些通道后图片会被重新压缩文件名变成乱码后面想回到 iOS 相册里对应原始照片就非常难。正确导出后你应该拿到一个目录里面是 IMG_0001.HEIC、IMG_0002.JPG 这类原始文件名以及 EXIF 里记录的拍摄时间。原始文件名是人工确认时最重要的地图脚本输出哪两张相似你能快速定位到 iOS 相册里对应原图。所以脚本第一段逻辑应该是递归遍历目录把完整路径和文件名记录下来。import os import argparse def collect_images(root_dir, exts(.jpg, .jpeg, .png, .heic, .bmp)): image_paths [] for dirpath, _, filenames in os.walk(root_dir): for name in filenames: if name.lower().endswith(exts): image_paths.append(os.path.join(dirpath, name)) return sorted(image_paths) if __name__ __main__: parser argparse.ArgumentParser(description收集目录下的图片文件) parser.add_argument(root, helpiOS 照片导出目录) args parser.parse_args() paths collect_images(args.root) print(f找到 {len(paths)} 张图片) for p in paths[:10]: print(p)这段代码的作用是递归扫描导出目录按扩展名过滤图片。注意 exts 参数默认包含 .heic因为新版 iPhone 默认照片格式就是 HEIC。如果你的 OpenCV 还读不了 HEIC第 5 章会讲怎么处理。先跑通这个函数确认路径数量和你相册里的照片数对得上能提前暴露导出不全的问题。3.2 用 OpenCV 计算 dHash 指纹并找出候选相似对照片收集好以后进入核心逻辑对每张图计算 dHash 指纹。选择 dHash 而非 aHash 或 pHash原因前面说过它对光照和轻微偏移更稳。实现时只需要 cv2.imread、cv2.cvtColor 和 cv2.resize 三步代码很短。import cv2 import numpy as np def dhash(image_path, hash_size8): img cv2.imread(image_path, cv2.IMREAD_COLOR) if img is None: return None img cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) resized cv2.resize(img, (hash_size 1, hash_size)) diff resized[:, 1:] resized[:, :-1] bits np.packbits(diff) return bits.tobytes() def hamming_distance(hash1, hash2): xor np.frombuffer(hash1, dtypenp.uint8) ^ np.frombuffer(hash2, dtypenp.uint8) return int(np.unpackbits(xor).sum())逻辑说明先用 cv2.imread 读图读不到就返回 None避免后面崩掉。然后转灰度把宽 resize 成 hash_size1、高 resize 成 hash_size这样相邻两列能组成 hash_size 组比较。diff 这一步比较“右边的像素是否比左边的亮”得到 8x864 个布尔值再用 np.packbits 压成 8 个字节的指纹。hamming_distance 对两个指纹做按位异或数出有多少位不同就是汉明距离。参数说明hash_size 默认取 8指纹长度 64 bit。如果相册里大量是 4000x3000 的高清照片这个尺寸够用如果相册里很多是截图、文档照片可以把 hash_size 提到 12指纹变成 144 bit误报率更低但匹配速度会稍微下降。这一步的性能瓶颈在 cv2.imread 的解码不在哈希计算。有了指纹接下来是两两比较。朴素双层循环在 1 万张图时会产生 5000 万次比较Python 层循环太慢。常见做法是先按文件大小分组再在小组内两两比较。完全相同的图片文件大小几乎一样文件大小差太多的一般不是重复图这个规则能砍掉绝大多数无意义比较。3.3 输出可读的分组结果相似对列表与重复候选项脚本跑到最后一定要输出人能看懂的中间结果而不是只打印“找到 123 对相似”。我早期把相似对打印成一行行路径结果发现存在传递关系A 和 B 像B 和 C 像但 A 和 C 并不像。如果只输出相似对你根本没法决定删谁。所以输出要分成两层第一层是候选相似组把存在传递关系的图片归到一组第二层是每张图的文件大小和时间戳。这样打开一个 HTML 报告或 CSV一眼就能看到该保留哪张、删除哪张。from collections import defaultdict def build_groups(paths, fingerprints, max_distance10): groups defaultdict(list) used set() n len(paths) for i in range(n): if paths[i] in used: continue group [paths[i]] used.add(paths[i]) for j in range(i 1, n): if paths[j] in used: continue if hamming_distance(fingerprints[i], fingerprints[j]) max_distance: group.append(paths[j]) used.add(paths[j]) if len(group) 1: groups[i] group return groups逻辑说明用 used 集合保证每张图只进入一个组避免同一张图被重复归组。外层从 i 开始内层只跟后面的图比较所以每个组里第一张图就是最早被遍历到的那张。这种做法不保证分组是全局最优但对相册去重已经足够代码也简单。参数说明max_distance 是汉明距离阈值默认给 10。这个值越大召回越激进误报也越多。当你看到输出里出现“两张完全不同的风景照被分到一组”就要把阈值往下压。实际使用时我会在 HTML 报告里直接嵌 base64 缩略图这样不用打开图片查看器就能快速划一遍所有候选组。上万张图时报告文件会很大所以我只对候选组里的图生成缩略图而不是全库。3.4 先跑通一个 100 张照片的迷你集正式处理上万张照片前我强烈建议先做一个迷你集验证。从目录里随机抽 100 张放在一个 test 文件夹里跑一遍上面的流程看看分组结果是否符合预期。这能同时验证照片导出是否完整、OpenCV 是否能正确解码、阈值是否离谱。迷你集跑通后再对全库执行。常见的错误是跳过这一步直接全量跑结果跑完发现 HEIC 全没读到或者文件名乱码还得回头改代码重跑。一个 100 张的小验证只需要一两分钟却能省下后面大量的返工时间。4. OpenCV 参数与性能调优哈希位数、阈值、并发数怎么定4.1 控制指纹长度的 resize 尺寸与哈希位数8 和 12 到底怎么选前文代码里的 hash_size 就是这个参数。默认 8 得到 64 bit 指纹好处是匹配速度快、内存占用低坏处是有可能把“构图相似但内容不同”的照片误判成相似。比如两张都是蓝天大海的照片dHash 比较相邻像素亮度变化指纹可能很接近这种情况在风景照里非常常见。把 hash_size 提高到 12 后指纹变成 144 bit对空间细节更敏感误报下降但哈希计算和比较都会更慢。我的经验是生活照为主、内容杂8 就够相册里大量是单反连拍或同场景多张用 12 更稳。没有绝对答案但有一个原则指纹长度提高后阈值要按新的汉明距离分布重新标定不能沿用 8 时的经验值。还有一个小技巧对 exif 方向标签做归一化。手机照片有时带旋转信息OpenCV 读出来是横的指纹自然对不上。可以用 exifread 或 piexif 读取 Orientation 字段先旋转成统一方向再算哈希。这一步看似多余却能解决一批“明明同一张图却匹配不上”的问题。4.2 汉明距离阈值标定先抽样一批真实相册再看误报率汉明距离阈值是这套脚本里最“玄学”的参数。不同相册内容最优阈值可能差一倍。不要拍脑袋设 10 就开跑我一般会抽 200 到 500 张图做子集先把所有两两距离算出来看分布直方图再人为挑出几个典型的“相似”和“不相似”例子看它们落点在哪。实际操作先跑一遍全库记录下所有小于等于 20 的相似对随机抽 50 组用人眼判断其中多少组是真相似。如果 50 组里有 10 组明显不是说明阈值太高往下降如果真相似组一个没漏但候选组总数太少可以适当往上升。这个过程最多花 20 分钟却能让后面的清理工作舒服很多。很多人跑完一遍抱怨误报多问题几乎都出在跳过了这个标定步骤。我给自己的相册设的原则是dHash 64 bit 时汉明距离小于等于 6 算“几乎同一张”小于等于 10 算“值得人工看一眼”小于等于 14 算“可能有关联”。最终自动删除只处理小于等于 6 的小于等于 10 的只标记不自动删。4.3 文件大小分桶把五千万次比较降到几千次一万张图两两比较是约 5000 万对即使每对计算很快Python 层循环也会拖到分钟级。常见的优化是先按文件大小分桶每个桶内再做两两比较。背后逻辑是完全相同的图片文件大小几乎一样同一场景连拍可能因为压缩率不同而差几百 KB但不会差到几 MB。所以文件大小差距超过某个阈值的图可以直接跳过。import os from collections import defaultdict def bucket_by_size(paths, tolerance1024): buckets defaultdict(list) for p in paths: size os.path.getsize(p) buckets[size // tolerance].append(p) return [group for group in buckets.values() if len(group) 1]逻辑说明把文件大小除以 tolerance 后取整作为桶 key。tolerance 取 1024意味着 1KB 以内的文件大小差异会被分到同一桶。桶内部再做哈希两两比较。这个优化对“完全重复文件”特别有效因为它们的文件大小完全一样。参数说明tolerance 不是固定值。iPhone 连拍照片之间的文件大小可能差几千字节我建议调到 2048 或 4096。注意这个分桶只能作为预筛不能替代哈希判断因为不同图片可能恰好有相同大小。分桶之后候选对数量通常会从上千万降到几千后续处理就轻松很多。4.4 多进程加速并发数、内存与 CPU 温度一万张照片算 dHash单线程在普通笔记本上大约要 5 到 10 分钟瓶颈是 cv2.imread 的 IO 和解码。加速办法是用 multiprocessing.Pool 把图片路径列表分发到多个进程每个进程独立算指纹最后汇总。OpenCV 本身线程安全但 Python 多线程受 GIL 限制所以这里必须用多进程不能只换线程。from concurrent.futures import ProcessPoolExecutor def compute_hash(path): fp dhash(path, hash_size8) return path, fp with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(compute_hash, paths))这段代码把 paths 列表分给 4 个进程去跑每个进程返回路径和指纹。参数说明max_workers 建议设为 CPU 物理核心数减一给系统留一个核做 IO 调度。笔记本散热一般4 到 6 个 worker 就够不要贪多否则 CPU 降频后速度反而变慢。内存控制是另一个容易翻车的地方。计算哈希阶段一次只处理一张图内存没问题但如果你把所有原图缩略图都塞进内存最后生成 HTML 报告时可能直接内存溢出。我的习惯是只把指纹和路径放在内存里缩略图在生成报告时一张张读、用完就释放。5. 常见问题和避坑指南HEIC 读取失败、误判与内存溢出5.1 HEIC 文件读不出来现象cv2.imread 对 HEIC 图片返回 None脚本报错或直接把这张图跳过最后结果里少了一批照片。原因OpenCV 官方编译版本默认不支持 HEIC 解码。HEIC 是 iOS 默认照片格式基于 HEVC 编码需要 libheif 或 ImageIO 后端才能解析。解决最常见做法是先把 HEIC 批量转成 JPG再交给 OpenCV。macOS 上用系统自带的 sips 命令就能搞定Windows 上可以用 PowerShell 调 Windows 影像组件或者提前在 iPhone 设置里把相机格式改成“兼容性最好”让导出的文件直接是 JPG。注意转格式会丢部分 EXIF转之前最好先保留原文件名。如果一定要在 OpenCV 里直接读需要自己编译带 libheif 的 OpenCV成本很高对大多数人来说不划算。提示如果脚本报告“找到 0 张图片”先确认是不是 HEIC 被读成 None。这是最常见的翻车点不是代码逻辑问题。5.2 截图和编辑照片导致误判现象iPhone 截图和一张网页长图被分到同一组或者一张编辑过的照片和原图被判定为“完全不一样”。原因截图通常带状态栏、圆角、白边整张图的像素布局被改变了。dHash 比较相邻像素亮度变化加了大量 UI 元素的截图和原始网页截图之间会有不小距离。而 iOS 编辑照片时保存的是“原始图加编辑记录”导出后可能是同一张图的多个版本内容一样但分辨率或色彩空间不同哈希也可能不一致。解决对截图可以在预处理时裁掉顶部状态栏区域再算指纹或者干脆把截图单独排除。对编辑照片建议改用 pHash因为它对亮度、尺寸变化更宽容。如果你发现“同一张图的两个版本”没有被找出来先把阈值调大 2 到 3 试试还不行就换算法不要死磕一个参数。5.3 大批量处理时内存溢出现象跑到一半脚本被杀掉或者生成报告时电脑卡死。原因常见于把缩略图、原图、指纹全部塞进内存或者用 DataFrame 装了几十万行两两比较结果。两两比较的数量级是 O(n^2)一万张图就有约 5000 万对如果全部存到内存里再排序几十 GB 都不够。解决不要在内存里生成全量两两对。正确做法是分桶先按文件大小分组再在小组内做两两比较见 4.3 节。生成报告时逐组写入不要一次性构建整个字符串再写文件。如果照片量在十万张以上建议用 SQLite 存指纹边算边写避免内存峰值。5.4 文件名和路径中的中文与空格问题现象脚本在 Windows 上跑部分图片路径包含中文目录名cv2.imread 返回 None。原因OpenCV 的 imread 在 Windows 上对非 ASCII 路径支持不好这是老问题。Python 3 的字符串路径本身没问题但 OpenCV 底层用的是 C 文件访问会把 UTF-8 路径当成本地编码处理。解决有两种常见方案。第一种是把整个目录临时复制到纯英文路径下再跑第二种是用 np.fromfile 读入字节流再用 cv2.imdecode 解码。import cv2 import numpy as np def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) if data.size 0: return None return cv2.imdecode(data, cv2.IMREAD_COLOR)参数说明np.fromfile 按原始字节读入文件cv2.imdecode 负责解码成 OpenCV 的 BGR 图像。这个函数可以作为 cv2.imread 的直接替代品几乎可以解决所有系统下的中文路径问题。另外输出 CSV 时也要注意编码Excel 打开中文 CSV 容易乱码建议输出 UTF-8 with BOM或者直接用 HTML 报告。5.5 完全相同的文件与相似图片混淆现象同一个文件被复制了两份文件名不同但内容完全相同脚本把这两份归到了“相似”里。原因dHash 本身能识别完全相同的内容但它的粒度不一定区分“同一张图的两个不同文件”和“两张内容完全一致的图”。这本身不算错误但如果你既想删重复文件又不希望误伤同内容但不同用途的图需要把文件级去重和图片级去重分开处理。解决先用 MD5 或 SHA1 对文件做一次精确去重。文件哈希完全一致基本可以断定是同一文件的复制品。这一步用 Python 的 hashlib 就能做速度比图像处理快得多。处理完精确重复后再用 OpenCV 做相似图片筛选两层互不干扰结果也更干净。6. 从“找出相似”到“安全清理”索引剪枝与人工复核的落地习惯6.1 用倒排索引替代全量两两比较一万张图的朴素两两比较是约 5000 万对即使每对计算很快仍然浪费时间。更聪明的做法是把指纹当作 key 构建倒排索引只有同一个桶内部的图片才需要两两比较。dHash 的 64 bit 指纹适合精确匹配完全相同的图片指纹完全一样。但相似图片的指纹不一定完全相等所以常见做法是以“部分位相同”作为分桶依据。我常用的技巧是把 64 bit 指纹拆成 4 个 16 bit 的段取其中两段作为 key。两张相似图片如果只差 10 bit大概率至少有一段完全相同所以它们会落到同一个桶里。这个思路能显著减少候选对数量实现也不复杂。from collections import defaultdict def build_index(fingerprints, seg16): idx defaultdict(list) for path, fp in fingerprints: raw fp.hex() segments [raw[i:iseg] for i in range(0, len(raw), seg)] for a in range(len(segments)): for b in range(a1, len(segments)): key (a, segments[a], b, segments[b]) idx[key].append(path) return idx逻辑说明每个指纹被拆成若干段每次取两段作为桶 key路径被放进对应桶里。比较时只在同一个桶内部做两两匹配。这个索引本质是用空间换时间index 字典里会有很多重复的路径引用但路径只是字符串内存可以接受。如果你发现召回不够可以把 seg 改小比如拆成 8 个 8 bit 段桶会更密集。6.2 人工复核的最小可行方案自动找出相似对可以交给代码但“删哪张”这件事我建议永远保留人工确认。原因很简单你可能想保留那张带实况照片或者有编辑记录的版本算法无法理解这些语义。人工复核的最小方案是在生成候选组后按组查看缩略图每组选一张保留其余移动到一个“待删除”目录。我通常会在每个候选组旁边输出文件大小和拍摄时间优先保留拍摄时间更早的那张因为原图通常在时间上更接近现场如果两张拍摄时间几乎一样再比较文件大小保留较大的一张。这个规则对连拍特别有效。删除阶段最安全的操作不是直接删文件而是先把要删的路径写进一个 delete_list.txt再写一段脚本按列表移动回收站。这样即使判断错了也有后悔药。6.3 我的使用习惯与后续自动化思路这个方向我做过不只一次。第一次用纯 dHash阈值调太松误报一堆第二次加了文件大小分桶速度快了很多第三次加了特征点精排把裁剪和旋转的情况也覆盖了。现在固定流程是先导出照片再用索引剪枝跑一遍输出候选组人工划一遍最后删除。整个过程对一万张照片大概 30 分钟其中人工复核占 20 分钟。如果你想更进一步可以定期只处理新增照片避免每次全量重跑。另一个方向是接入神经网络做场景聚类但那是后话在绝大多数“照片太多、重复太多”的场景里OpenCV 的感知哈希已经够用而且可解释、可调试。最后想分享一个教训第一次跑通脚本时我为了验证效果把最大汉明距离设到 15结果一张普通食物照片和一张书房照片被分到同一组差点让同事以为脚本废了。后来才明白与其花时间调一个万能阈值不如先把阈值标定和人工复核做到位。清理相册这件事技术只负责把相似照片找出来留不留终究得自己决定。希望这个方案能帮你也把相册里那些重复照片理清楚。本文还有配套的精品资源点击获取