引言
我有超过 10 万张照片,散落在多个硬盘、云盘和移动设备上。它们既没有被分类,也没有被去重,甚至连"我到底拍了多少张"都不知道。
我尝试过几种方案:
- 云相册(Google Photos、iCloud):方便,但隐私成本高。我的照片上传到别人的服务器,索引逻辑也不透明。
- 文件管理器:能看到文件,但无法查询。想找"所有包含猫的蓝色照片"?没门。
- 本地相册应用(Lightroom、Apple Photos):功能强大,但它们是"查看器",不是"数据层"。照片被导入后,原始文件的管理权就交给了应用,难以用脚本查询。
我需要的是介于两者之间的东西:一个本地的、可查询的、索引与文件解耦的照片数据层。
这就是 photo-library 的定位——一个基于 SQLite + REST API 的本地照片索引服务。它不显示照片,不管理文件,它只做一件事:让照片可被查询。
这个系统有 4 个关键的设计决策,每个都解决了传统方案中的一个痛点。下面逐一拆解。
速览(TL;DR)
- 增量扫描:用 (size, mtime) 指纹跳过未变文件,约 5% 文件变化时重扫从 ~8 分钟降至 ~15 秒
- 相似图聚类:aHash 高 16 位分桶 + 桶内两两比较,把全表 O(n²) 降到近似线性;blake3 内容哈希单独处理精确重复
- disposition 状态机:
undecided / keep / remove三态,人工决策持久化,磁盘文件永不触碰 - 原图不触碰原则:索引先行、只写 SQLite 元数据,
batch_upsert不含任何文件移动/删除
决策一:增量扫描——为什么不全量重扫
直觉做法
最朴素的想法:每次扫描时,遍历所有图片,重新解码、计算 blake3 哈希、计算 aHash、分析元数据,然后写入数据库。
痛点
我的照片库有 10 万+ 张图。全量解码和哈希需要约 8 分钟。如果我只拍了一张新照片,仍然要等 8 分钟才能看到结果。这在用户体验上是不可接受的。
实际做法
我的解决方案是指纹跳过:先加载存量库的 (size, mtime) 指纹,扫描时比对指纹是否变化,只有变化的文件才重新解码和分析。
并发解码由 scan_chunk() 控制每批数量,避免大图(单张解码后可达数十 MB)并发过高撑爆内存:
/// 每批并发解码数。大图(佳能原图 ~66MB 解码后)并发过高是扫描期内存与 CPU 峰值主因。
/// 默认 4:扫描时 CPU 不再被吃满(M3 8 核下约 30-50%),换低速约 2-3 倍;
/// 可用环境变量覆盖(如设 8 更激进)。
fn scan_chunk() -> usize {
std::env::var("SCAN_CONCURRENCY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4)
}
而真正的"跳过"发生在 read_image_file:指纹命中就直接返回 None,连解码都不做。
// 文件未变化:跳过重解码,增量扫描由此从分钟级降到秒级。
if signatures.get(&relative_path) == Some(&(size_bytes, mtime)) {
return Ok(None);
}
效果
在我的 10 万张照片库上,如果只有约 5% 的文件发生变化,重扫时间从 ~8 分钟降至 ~15 秒。加速比超过 30x。
决策二:相似图聚类——为什么不用全表 O(n²)
问题
10 万张图片的两两比较次数是 C(100000, 2) ≈ 50 亿次。即使每次比较只需 1 微秒,总耗时也是 5000 秒,约 83 分钟。这还只是比较阶段,加上预处理时间,完全不可行。
细节:感知哈希用 aHash
相似检测不比较像素,而是比较感知哈希。photo-library 用 aHash(平均哈希):把图缩到 8×8 灰度,逐像素与均值比较生成 64bit。
/// 平均哈希(aHash):缩到 8×8 灰度,逐像素与均值比较生成 64bit。
pub(crate) fn compute_phash(img: &DynamicImage) -> Option<i64> {
let gray = img.resize_exact(8, 8, image::imageops::FilterType::Lanczos3).to_luma8();
let pixels: Vec<u32> = gray.iter().map(|v| *v as u32).collect();
let mean = pixels.iter().sum::<u32>() / pixels.len() as u32;
let mut hash = 0u64;
for (i, &p) in pixels.iter().enumerate() {
if p >= mean {
hash |= 1u64 << (63 - i as u64);
}
}
// ...
}
细节:精确重复排除 + 分桶
一个重要的边界情况:如果两张图片的 content_hash(blake3 哈希)完全相同,它们是精确重复,不需要参与相似合并。否则,重新编码但内容相同的图片会被误判为"相似"而非"重复"。
聚类本身用分桶避免全表比较,桶内才做两两汉明距离比较:
/// 相似图聚类:aHash 距离 ≤ 阈值、但内容哈希不同(排除精确重复)合并为一组。
/// 先按感知哈希的高 16 位分桶,桶内两两比较,避免全表 O(n²)。
pub(crate) async fn similar(&self) -> Result<SimilarPage, ApiError> {
这样,精确重复由 blake3 通道处理,相似近重复由 aHash 通道处理,两者互不干扰。
决策三:人工决策持久化——为什么用 disposition 状态机
优势
重复/相似判定只是"建议",最终要不要删由人决定。photo-library 用三态枚举把决策持久化下来:
pub(crate) enum Disposition {
Undecided,
Keep,
Remove,
}
- 可逆:用户可以反复修改 disposition,直到满意为止
- 透明:用户可以预览哪些文件会被删除,再确认
- 非破坏性:索引服务本身不执行删除,用户拥有最终决定权
决策四:原图不触碰原则——为什么索引先行
结果
这个原则让 photo-library 成为一个可靠的底层服务,用户可以放心地在其上层构建任何功能(如自动备份、智能分类、照片分析),而不必担心索引服务会意外修改文件。
具体落地是:内容哈希只"读"不"写"原图——hash_file 流式读取计算 blake3,全程不落盘、不移动:
/// blake3 内容哈希(流式,避免大图整读进内存)。
fn hash_file(path: &Path) -> std::io::Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
// ...
}
而 batch_upsert 只把元数据写进 SQLite,任何阶段都不包含文件移动或删除。这样索引服务和"文件管理权"彻底解耦。
结语
photo-library 的核心目标不是"管理照片",而是"让照片可查询"。这个目标决定了它的架构:
- 增量扫描保证效率
- 分桶聚类保证可扩展性
- disposition 状态机保证安全性
- 原图不触碰原则保证可靠性
这 4 个决策相互支撑,共同构成一个稳定、可扩展、用户可控的本地照片数据层。
如果你也有大量照片需要管理,或者想在自己的项目中使用照片索引能力,欢迎查看 photo-library。
常见问题(FAQ)
为什么不直接用 Immich、PhotoPrism 这类成熟方案,而要自己写一个?
定位不同。photo-library 不是一个”相册查看器”,而是一层”照片数据索引”:索引存于本地 SQLite、与文件树解耦、可增量重扫,并通过 REST API 暴露,方便在其上搭脚本或上层应用;原图字节从不移动、也不上传任何服务器。如果你要的是 Google Photos 式日常体验,Immich 更合适;如果你要的是”把照片库变成可查询的数据集”,这才是它的位置。
10 万张照片每次扫描都要重算一遍哈希吗?增量扫描怎么做到秒级?
不会。扫描时先加载存量库的 (size, mtime) 指纹表,对每张文件比对 signatures.get(&relative_path) == Some(&(size_bytes, mtime)),命中就直接跳过解码与哈希,函数返回 None。只有真正变化的文件才重新计算。实测约 5% 文件变化时,重扫从约 8 分钟降到约 15 秒。
相似图(近重复)检测用什么算法?为什么不直接全表两两比较?
感知哈希用 aHash(平均哈希):把图缩到 8×8 灰度,逐像素与均值比较生成 64bit(函数 compute_phash)。相似聚类先按 aHash 高 16 位分桶、桶内两两比较汉明距离,避免对 10 万张做 O(n²) 全表比较;同时用 blake3 内容哈希排除”精确重复”,确保内容相同的图不走相似合并。
去重、标记相似之后,会动我的原图文件吗?
不会。索引服务是纯读操作:batch_upsert 只写 SQLite 元数据,从不移动或删除原图。重复/相似判定结果以 disposition 三态(undecided / keep / remove)持久化,真正删除由用户显式触发,索引层不执行任何文件写操作。
扫描大图库时会不会把内存或 CPU 吃满?
扫描用 scan_chunk() 限制每批并发解码数,默认 4,避免大图(单张解码后可达数十 MB)并发过高造成内存与 CPU 峰值;该值可用环境变量调高以加速。解码等阻塞 IO 全部走 spawn_blocking,不阻塞异步运行时。
为什么内容哈希选 blake3?
精确去重依赖内容哈希;photo-library 用 blake3::Hasher 做流式哈希(函数 hash_file),边读边算、不把整张大图读进内存,抗碰撞性强。两张图 content_hash 相同即判定为精确重复,由去重流程独立处理。
源码导航
src/store.rs— 增量扫描 / 指纹跳过:scan_chunk并发控制、read_image_file按 (size, mtime) 跳过未变文件src/store.rs— 内容哈希:hash_file用 blake3 流式哈希做精确去重src/perception.rs+src/store.rs— 感知哈希 / 相似聚类:compute_phash(aHash) 与similar高 16 位分桶聚类src/model.rs— 数据模型 / 处置状态:Image、Disposition(undecided/keep/remove)src/api.rs+src/file_token.rs— 文件服务与鉴权:image_file原图服务、file_token短时签名 token
项目地址
- GitHub 仓库:https://github.com/erishen/photo-library
发表回复