本地照片索引:4 个设计决策,把”文件堆”变成可查询的数据集

作者:

🇬🇧 English

引言

我有超过 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,
}
  1. 可逆:用户可以反复修改 disposition,直到满意为止
  2. 透明:用户可以预览哪些文件会被删除,再确认
  3. 非破坏性:索引服务本身不执行删除,用户拥有最终决定权

决策四:原图不触碰原则——为什么索引先行

结果

这个原则让 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 — 数据模型 / 处置状态:ImageDisposition(undecided/keep/remove)
  • src/api.rs + src/file_token.rs — 文件服务与鉴权:image_file 原图服务、file_token 短时签名 token

项目地址

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

首页 简历 商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号