简介这份资源面向iOS开发初学者与计算机视觉爱好者提供一套基于OpenCV实现相册相似图片快速筛选的完整工程源码可用于毕业设计、课程设计或图像去重功能的学习参考。压缩包共57个文件约56KB以Objective-C源文件.h/.m为主体配合Xcode工程配置.pbxproj、.xcworkspacedata、.plist、.storyboard及Podfile依赖描述另含少量json、mm与png资源结构完整可直接编译运行。工程围绕AMImageCompareCore核心模块展开结合CollectionView与Category分类封装演示了图像特征提取、相似度比对与结果展示的完整链路并配有README说明与单元测试文件便于理解模块划分与调用关系。目前已有153人学习下载适合希望快速上手OpenCV图像处理、需要现成iOS工程骨架进行二次开发的读者参考借鉴。1. 从 iPhone 相册里捞出几千张相似图OpenCV 能帮你省下多少手动翻找的时间手机相册里存了几千张照片其中大量是连拍、截图、微信保存的重复图以及同一场景下反复按快门留下的“差不多”版本。手动一张张翻眼睛会花手指会酸更别提 iPhone 自带的“重复项目”检测只认完全相同的文件稍微裁切、调色、加滤镜就识别不出来了。这个标题指向的方案核心思路是用 OpenCV 做感知哈希或特征点匹配把“看起来像”的图片找出来再批量筛选删除。它适合两类人一是相册已经膨胀到 128G 快满、又懒得手动整理的人二是想拿这个场景练手 OpenCV 图像处理、顺便解决实际问题的开发者。整条链路不复杂但有几个参数和坑点直接决定你是筛出几百张真重复还是误伤几千张正常照片。2. 相似图片筛选的底层逻辑为什么直方图比对不够用2.1 从像素到指纹三种主流相似度算法选型做相似图片筛选本质是把图片转成一个可比较的“指纹”再计算指纹之间的距离。常见做法有三类感知哈希pHash、颜色直方图、ORB 特征点匹配。pHash 对缩放、轻微调色、水印有较好鲁棒性计算速度极快适合先做粗筛颜色直方图对整体色调敏感但遇到构图相似、颜色不同的图容易误判ORB 特征点匹配精度最高能识别旋转、裁切后的同一场景但计算量大不适合直接跑几千张图的全量比对。我一般会采用两级策略第一级用 pHash 把每张图转成 64 位二进制指纹计算汉明距离距离小于 10 的归为候选相似组第二级对候选组内用 ORB 做验证排除 pHash 的误报。这样既控制了计算量又保证了准确率。选型理由很直接iPhone 相册里大量重复图是连拍和截图pHash 对这类“整体结构相同、局部有差异”的场景命中率很高而 ORB 能兜住那些被裁切过的图。2.2 用 Python 和 OpenCV 计算 pHash 指纹下面这段代码读取指定目录下的所有图片统一缩放到 32x32 灰度图做 DCT 变换后取左上角 8x8 低频区域生成 64 位哈希值。代码里对 EXIF 旋转做了处理避免 iPhone 竖拍照片被当成横图处理。import cv2 import numpy as np import os from PIL import Image, ImageOps def phash(image_path, hash_size8, highfreq_factor4): # 用 PIL 读取并自动应用 EXIF 旋转 img Image.open(image_path) img ImageOps.exif_transpose(img) img img.convert(L).resize( (hash_size * highfreq_factor, hash_size * highfreq_factor), Image.LANCZOS ) pixels np.array(img, dtypenp.float32) # DCT 变换取低频部分 dct cv2.dct(pixels) dct_low dct[:hash_size, :hash_size] # 用中位数作为阈值生成二进制指纹 med np.median(dct_low) diff dct_low med return diff.flatten() def hamming_distance(hash1, hash2): return np.count_nonzero(hash1 ! hash2)参数说明hash_size8表示最终生成 64 位指纹这是精度和速度的平衡点highfreq_factor4表示先缩放到 32x32 再做 DCT保留更多频域信息。ImageOps.exif_transpose是关键iPhone 照片的 EXIF 方向信息不处理的话竖图会被当成横图导致同一张照片旋转后哈希值完全不同。汉明距离阈值一般设在 8 到 12 之间低于 8 基本是同一张图高于 12 误报率会明显上升。2.3 批量扫描相册目录并输出相似组拿到所有图片的哈希值后用并查集或简单的两两比对把相似图分组。下面代码遍历目录、计算哈希、按汉明距离聚类最后输出每组相似图片的路径列表。def scan_and_group(root_dir, threshold10): hash_dict {} for dirpath, _, filenames in os.walk(root_dir): for fname in filenames: if fname.lower().endswith((.jpg, .jpeg, .png, .heic)): fpath os.path.join(dirpath, fname) try: hash_dict[fpath] phash(fpath) except Exception as e: print(f跳过 {fpath}: {e}) paths list(hash_dict.keys()) visited set() groups [] for i in range(len(paths)): if paths[i] in visited: continue group [paths[i]] visited.add(paths[i]) for j in range(i 1, len(paths)): if paths[j] in visited: continue dist hamming_distance(hash_dict[paths[i]], hash_dict[paths[j]]) if dist threshold: group.append(paths[j]) visited.add(paths[j]) if len(group) 1: groups.append(group) return groups逻辑说明外层循环遍历每张未访问的图片作为种子内层循环找所有与它汉明距离小于阈值的图归入同一组。threshold10是经验值实际跑的时候可以先设 8 跑一遍看召回再设 12 跑一遍看误报取中间值。注意.heic格式 OpenCV 默认不支持需要额外装pillow-heif库否则会直接报错跳过。如果相册目录层级很深os.walk会递归所有子目录建议先确认目录结构再跑。3. 把筛选结果落地从哈希列表到可操作的删除清单3.1 用 ORB 特征点做二次验证pHash 跑完可能得到几十个相似组但其中有些是“构图相似、内容不同”的误报比如同一角度拍的两张不同文档。这时候用 ORB 做二次验证计算两张图的特征点匹配数匹配数低于阈值的从组里剔除。def orb_similarity(img1_path, img2_path, min_match30): img1 cv2.imread(img1_path, cv2.IMREAD_GRAYSCALE) img2 cv2.imread(img2_path, cv2.IMREAD_GRAYSCALE) if img1 is None or img2 is None: return False orb cv2.ORB_create(nfeatures500) kp1, des1 orb.detectAndCompute(img1, None) kp2, des2 orb.detectAndCompute(img2, None) if des1 is None or des2 is None: return False bf cv2.BFMatcher(cv2.NORM_HAMMING, crossCheckTrue) matches bf.match(des1, des2) # 过滤掉距离过大的匹配点 good [m for m in matches if m.distance 50] return len(good) min_match参数说明nfeatures500控制提取的特征点数量太多会慢太少会漏min_match30是判定为同一场景的最低匹配点数实际测试中 30 到 50 之间比较稳。crossCheckTrue保证匹配是双向的减少误匹配。这段代码只对 pHash 分出来的候选组内图片两两跑不会拖慢整体速度。3.2 生成带缩略图的 HTML 报告方便人工确认自动筛选再准最终删不删还是得人看一眼。我一般会生成一个 HTML 页面把每组相似图并排展示标注文件路径和汉明距离方便快速过一遍。def generate_html_report(groups, output_pathreport.html): html [htmlbodyh2相似图片分组报告/h2] for idx, group in enumerate(groups): html.append(fh3组 {idx 1}{len(group)} 张/h3div styledisplay:flex;flex-wrap:wrap;) for img_path in group: html.append( fdiv stylemargin:5px;text-align:center; fimg srcfile://{img_path} height150br fsmall{os.path.basename(img_path)}/small/div ) html.append(/div) html.append(/body/html) with open(output_path, w, encodingutf-8) as f: f.write(.join(html))逻辑说明每组图片横向排列缩略图高度固定 150px下方显示文件名。file://协议在本地浏览器直接打开就能看到图不需要起服务器。注意路径里有中文或空格时某些浏览器可能加载失败建议先把图片复制到纯英文路径再生成报告。这个报告不负责删除只负责让你肉眼确认哪些组确实该删。3.3 安全删除策略先移入回收站再清理直接os.remove风险太大万一误判就是血泪教训。稳妥做法是把要删的图移到临时目录确认没问题后再手动清空。import shutil def move_to_trash(groups, trash_dir./trash): os.makedirs(trash_dir, exist_okTrue) moved 0 for group in groups: # 每组保留第一张其余移入回收站 for img_path in group[1:]: dest os.path.join(trash_dir, os.path.basename(img_path)) # 处理重名 if os.path.exists(dest): base, ext os.path.splitext(os.path.basename(img_path)) dest os.path.join(trash_dir, f{base}_dup{ext}) shutil.move(img_path, dest) moved 1 print(f共移动 {moved} 张图片到 {trash_dir})参数说明group[1:]表示每组保留第一张其余视为重复。这个策略适合连拍场景但如果是同一场景不同角度可能每张都有保留价值这时候需要人工在 HTML 报告里勾选。trash_dir建议设在同盘符下shutil.move跨盘符会变成复制加删除速度慢且可能失败。4. 避坑与排查iPhone 相册场景下最容易翻车的五个点4.1 HEIC 格式直接报错或跳过现象跑脚本时大量.heic文件被cv2.imread返回None或者 PIL 直接抛UnidentifiedImageError。原因OpenCV 默认不支持 HEIC 解码PIL 也需要额外插件。解决安装pillow-heif在代码开头注册register_heif_opener()之后 PIL 就能正常读取 HEIC再转成 numpy 数组给 OpenCV 用。如果不想装额外库可以先用 iPhone 导出为 JPEG但会损失一些画质。4.2 EXIF 旋转导致同一张图哈希值不同现象同一张照片在相册里看是竖的脚本处理后变成横的和另一张旋转过的副本哈希距离很大没被分到一组。原因iPhone 拍照时把方向信息写在 EXIF 里像素数据本身是横的。解决读取时用ImageOps.exif_transpose自动旋转或者用cv2.ROTATE_90_CLOCKWISE根据 EXIF 方向手动转。这一步不做后面所有比对都是错的。4.3 汉明距离阈值设太小导致漏筛现象明明肉眼看着一样的图脚本没分到一组。原因pHash 对局部裁剪、贴纸、文字水印比较敏感阈值设 6 以下时稍微裁过的图距离可能到 10 以上。解决先用 12 跑一遍看召回再逐步降到 8 看误报找到平衡点。也可以对图片先做一次中心裁剪再算哈希减少边缘差异的影响。4.4 内存不足导致大批量处理中断现象跑几千张图时进程被系统杀掉或者报MemoryError。原因一次性把所有图片读进内存算哈希或者 ORB 特征点没及时释放。解决逐张读取、算完哈希立刻释放图像对象不要把所有原图缓存在列表里。ORB 二次验证时也只读候选组内的图不要全量加载。如果图片特别多可以分批处理每 500 张存一次中间结果。4.5 路径含中文或特殊字符导致读取失败现象某些图片路径里有中文、emoji 或空格cv2.imread返回None。原因OpenCV 在 Windows 下对非 ASCII 路径支持不好。解决用np.fromfile加cv2.imdecode替代cv2.imread或者先用 PIL 读取再转 numpy。生成 HTML 报告时也要对路径做 URL 编码否则浏览器加载不出来。5. 进阶技巧用感知哈希做增量扫描和相册去重流水线跑通基础流程后下一步是把它变成可持续用的工具。我自己的习惯是维护一个hash_cache.json记录每张图片的路径、文件大小、修改时间和 pHash 值。每次扫描时先比对文件修改时间没变过的直接读缓存只对新图片算哈希。这样第二次扫描几千张图只需要几秒而不是重新跑几分钟。import json import hashlib def file_fingerprint(path): stat os.stat(path) return f{stat.st_size}_{int(stat.st_mtime)} def load_cache(cache_pathhash_cache.json): if os.path.exists(cache_path): with open(cache_path, r) as f: return json.load(f) return {} def update_cache(cache, root_dir): for dirpath, _, filenames in os.walk(root_dir): for fname in filenames: if fname.lower().endswith((.jpg, .jpeg, .png, .heic)): fpath os.path.join(dirpath, fname) fp file_fingerprint(fpath) if fpath not in cache or cache[fpath][fp] ! fp: try: h phash(fpath) cache[fpath] { fp: fp, hash: h.tolist() } except Exception: pass return cache这个缓存机制的关键是file_fingerprint用文件大小和修改时间做联合判断比单纯用路径可靠。如果照片被编辑过修改时间会变缓存自动失效重新计算。hash存成列表是因为 JSON 不支持 numpy 数组读回来的时候用np.array(cache[fpath][hash], dtypebool)还原。另一个实用技巧是把整个流程封装成命令行工具支持--scan、--report、--clean三个子命令。扫描阶段只算哈希和分组报告阶段生成 HTML清理阶段执行移动。三个阶段分开的好处是你可以先看报告再决定删不删不会一键下去后悔莫及。我一般会在清理前把trash目录同步到 iCloud 或备份盘放一周确认没问题再彻底删除。最后说一个验证方法拿 100 张已知重复的图做测试集跑一遍看召回率和准确率。召回率低于 90% 就降阈值准确率低于 95% 就升阈值或加 ORB 验证。这个测试集不用很大但能帮你快速找到适合自己相册的参数组合。我自己跑下来pHash 阈值 10 加 ORB 匹配 30 点在 iPhone 相册场景下召回 93% 左右误报控制在 5% 以内剩下的靠 HTML 报告人工过一遍基本能清干净。希望帮到你。本文还有配套的精品资源点击获取