导语
Windows 上的照片管理工具不少,但能在本地跑 AI 语义搜索、人脸识别、智能去重的桌面应用不多。本文分享一个从零搭建的开源项目,重点聊聊技术选型、架构设计和 AI 推理在 Electron 中的落地经验。
为什么又造了个轮子
市面上现有的方案各有各的痛点:
- 看图软件(ImageGlass/qimgv):看图行,管理为零
- Eagle:设计素材管理神器,但会拷贝文件打乱原有目录结构,且收费
- Immich/PhotoPrism:功能强但要部署 Docker 服务端,不是双击即用的桌面应用
- Lap:今年很火的 Rust + Tauri 方案,轻量但功能相对基础
我想要的是:双击 exe 打开,直接索引现有文件夹,AI 搜索/去重/标签/人脸识别全打包,完全离线。 没有现成的,就自己写。
项目地址:github.com/Uyoung666/a…,MIT 协议,欢迎 Star & PR。
技术栈
| 层 | 选型 | 选型理由 |
|---|---|---|
| 桌面框架 | Electron 41 | 生态成熟,React/shadcn/ui 直接复用 |
| 前端 | React 19 + TypeScript 6 strict | 类型安全,strict 模式避免低级错误 |
| UI | shadcn/ui + Radix UI + Tailwind CSS 4 | 组件质量高,暗色模式开箱即用 |
| 构建 | Vite 8 | HMR 秒级热更新 |
| IPC | oRPC | 全类型安全的 RPC,渲染端调主进程像调本地函数 |
| 数据库 | better-sqlite3 + Drizzle ORM | 零配置,Drizzle 的 SQL-like API 比 Prisma 灵活 |
| 向量存储 | LanceDB | 嵌入式向量数据库,不需要额外服务 |
| AI 推理 | Transformers.js (ONNX Runtime) | 纯 JS 生态,CPU 推理够用 |
| 图片处理 | sharp | 性能最好的 Node.js 图片处理库 |
| 测试 | Vitest + Playwright | 单元 + E2E 覆盖 |
架构设计
三进程模型
┌─ Main Process ─────────────────────────────────────┐
│ 数据库 (better-sqlite3) │ 文件监听 (chokidar) │
│ 缩略图缓存 (LRU→磁盘→按需) │ IPC Server (oRPC) │
│ local-media:// 协议处理器 (sharp 实时转码) │
└────────────────────────────────────────────────────┘
│ │
MessagePort child_process.fork()
│ │
┌─ Renderer ─────────┐ ┌─ AI Workers ──────────────┐
│ React 19 │ │ embed-worker.mjs (CLIP) │
│ shadcn/ui │ │ face-worker.mjs (人脸) │
│ TanStack Router │ │ 独立进程,避免 native │
│ oRPC Client │ │ 模块冲突 │
└────────────────────┘ └───────────────────────────┘
为什么 AI 要放独立进程?因为 Transformers.js 的 ONNX Runtime 原生绑定和 sharp 的 libvips 在同一个 Node 进程中有时会产生 native module 冲突。用 child_process.fork() 隔离开,Worker 进程崩了也不影响主进程和 UI。
类型安全的 IPC
传统的 Electron IPC 长这样:
// 主进程
ipcMain.handle('search:byText', async (event, query, limit) => { ... })
// 渲染进程
const results = await ipcRenderer.invoke('search:byText', '日落', 50)
字符串 key 没有类型检查,参数顺序容易写错。
用 oRPC 后:
// 主进程定义 handler
export const searchByText = os
.input(SearchSchema) // Zod schema,编译时类型检查
.handler(async ({ input }) => { ... })
// 渲染进程调用
const { results } = await trpc.photos.searchByText.mutate({
query: '日落',
limit: 50,
})
// results 的类型自动推导,不需要手写 interface
这比传统 IPC 舒服太多了——重构时只要改了 Schema,调用方编译就报错。
local-media:// 协议
Electron 访问本地文件通常用 file:// 协议或 protocol.handle。我注册了一个自定义协议 local-media://,好处是:
- 安全白名单:只有用户添加到索引的文件夹路径才能通过协议读取,随便输入一个系统文件路径返回 403
- 实时转码:遇到 TIFF/HEIC/RAW 等浏览器不支持的格式,sharp 自动转 PNG 返回
- 永久缓存:响应头设了
cache-control: immutable,同一张图不会重复请求
protocol.handle('local-media', async (request) => {
const filePath = decodeURIComponent(request.url.slice('local-media://'.length))
// 白名单校验
if (!isAllowedPath(filePath)) return new Response(null, { status: 403 })
// 浏览器原生支持的格式直接返回
if (browserCompatible.has(ext)) {
return new Response(buffer, {
headers: { 'content-type': mimeType, 'cache-control': 'public, max-age=31536000, immutable' }
})
}
// 不支持的格式 sharp 转 PNG
const converted = await sharp(filePath).png().toBuffer()
return new Response(new Uint8Array(converted), { ... })
})
AI 管线:从中文搜索到向量检索
整体流程
用户输入 "去年秋天在海边拍的日落"
│
▼
┌─ 中文解析 (query-parser.ts) ─┐
│ 词典匹配: 秋天→autumn, │
│ 海边→seaside, 日落→sunset │
│ 时间提取: 去年秋天 → │
│ 2025-09 ~ 2025-11 │
└──────────────┬───────────────┘
▼
┌─ 多 Prompt 生成 ────────────┐
│ "a photo of autumn seaside │
│ sunset" │
│ "a photograph of sunset" │
│ "a scenic photo at seaside" │
└──────────────┬───────────────┘
▼
┌─ CLIP 向量编码 (embed-worker.mjs) ─┐
│ sharp 预处理 → ONNX 推理 → 512维向量 │
└──────────────┬──────────────────────┘
▼
┌─ LanceDB 向量检索 ──────────┐
│ cosine 距离 + adaptive │
│ refine factor │
└──────────────┬───────────────┘
▼
┌─ RRF 融合排序 ──────────────┐
│ 多组结果加权合并 + 时间衰减 │
└──────────────┬───────────────┘
▼
返回结果
中文解析器:为什么不直接用 multilingual CLIP
一开始想用 multilingual CLIP 直接编码中文,测试后发现两个问题:
- ViT-B/32 级别的 multilingual 模型对中文精度下降明显
- 很多摄影术语有标准英文对应("逆光"="backlight"、"微距"="macro"),翻译后再编码反而更准
所以手写了一个 428 行的摄影词典,覆盖场景/人物/动物/物体/活动/光影/风格/色彩/天气 9 大类。用贪心最长匹配算法解析输入,把中文关键词翻译成英文后再生成 CLIP prompt。
// query-parser.ts 核心逻辑
export function parseChineseQuery(query: string): ParsedQuery {
const parsed: ParsedQuery = { subject: [], scene: [], time: [], ... }
let remaining = query.trim()
// 贪心匹配:长词优先
for (const key of SORTED_DICT_KEYS) {
if (remaining.includes(key)) {
const entry = ZH_TO_EN_SEARCH[key]
parsed[CATEGORY_TO_SLOT[entry.category]].push(key)
remaining = remaining.replaceAll(key, ' ')
}
}
// 剩余的单字走字级别分解
for (const char of remainingChars) {
if (CHAR_DECOMPOSE[char]) {
parsed[CATEGORY_TO_SLOT[CHAR_DECOMPOSE[char].category]].push(char)
}
}
return parsed
}
嵌入 Worker 的手动预处理
Transformers.js 的 AutoProcessor 在 Electron 的 fork 进程中有时会出问题。保险起见,Worker 里手写了 CLIP 预处理:
// embed-worker.mjs — 手动 CLIP 预处理
async function preprocessCLIP(filePath) {
const { data, info } = await sharp(filePath, { failOn: 'none' })
.resize(224, 224, { fit: 'cover', position: 'center' })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
// sharp → NCHW Float32Array
const floatData = new Float32Array(3 * 224 * 224)
for (let y = 0; y < 224; y++) {
for (let x = 0; x < 224; x++) {
const srcIdx = (y * 224 + x) * channels
for (let c = 0; c < 3; c++) {
const pixel = rgb[srcIdx + c] / 255.0
floatData[c * pixelsPerChannel + y * 224 + x] =
(pixel - CLIP_MEAN[c]) / CLIP_STD[c]
}
}
}
// 构造 ONNX Tensor,直接喂给模型
const pixelValues = new Tensor('float32', floatData, [1, 3, 224, 224])
const output = await cachedModel({ pixel_values: pixelValues })
// L2 归一化
const vec = Array.from(output.image_embeds.data)
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0))
return vec.map(v => v / (norm || 1))
}
如果手动预处理失败,还有 AutoProcessor 作为 fallback。两套方案保证 Worker 不会因为某张图卡住整批。
搜索融合策略
多个 prompt 产生多组搜索结果后,用 RRF(Reciprocal Rank Fusion)+ 加权合并:
// 权重递减:第1个 prompt 最重要,后续递减
const weights = [1.0, 0.7, 0.5]
const k = 60 // RRF 平滑参数
for (let i = 0; i < resultSets.length; i++) {
const w = weights[Math.min(i, weights.length - 1)]
for (let rank = 0; rank < resultSets[i].length; rank++) {
const rrfScore = w / (k + rank + 1)
const combined = rrfScore + similarity * w * 0.05
scores.set(photoId, (scores.get(photoId) || 0) + combined)
}
}
另外从中文时间词("去年""秋天""昨天")中提取时间范围,对匹配时段内的照片做相似度加权,让时间相关的结果排更前。
智能去重:三阶段流水线
所有照片
│
▼
阶段1: 文件哈希 ──── 头尾各4KB + 文件大小 SHA256
│ 找到完全相同的 → 直接标记 exact
▼
阶段2: pHash + BK-tree ── 感知哈希近似查询
│ 汉明距离 ≤ 8 → 候选重复
▼
阶段3: CLIP 向量精排 ── 余弦相似度验证
降低误判
BK-tree 的实现:
// bk-tree.ts — 支持海量 pHash 的高效近似查询
export class BKTree {
private root: BKNode | null = null
query(hash: string, threshold: number): Array<{ photoId: number; distance: number }> {
// 从根节点开始,只遍历距离在 [d-threshold, d+threshold] 范围内的分支
// 时间复杂度 O(log n) 而非 O(n)
}
insert(photoId: number, hash: string): void {
// 按汉明距离逐层插入
}
}
人脸识别管线
模型选型
- 检测:UltraFace-320(ONNX),模型仅 1.2MB,速度极快
- 特征提取:InsightFace w600k_r50(ONNX),512 维嵌入向量
聚类策略
新的人脸向量
│
▼
与所有已有身份的 centroid 计算余弦相似度
│
├─ 相似度 ≥ 0.55 → 归入该身份 → 更新 centroid
│
└─ 相似度 < 0.55 → 创建新身份
Centroid 是身份下所有人脸嵌入的平均向量(L2 归一化后)。用户手动合并身份后保留 confirmed 标记,后续重聚类时不拆散。
性能考虑
人脸检测是 CPU 密集型操作。处理策略:
- 20 张一批,batch 间有间隔
- 跳过已有检测结果的照片(断点续跑)
- 进度通过 IPC 实时推送到渲染进程
缩略图三级缓存
10 万+ 图片库要实现 60fps 滚动,缩略图策略是关键:
请求缩略图
│
▼
L1: 内存 LRU Cache ── 命中 → 直接返回(<1ms)
│ 未命中
▼
L2: 磁盘缓存 ── 命中 → 加载 + 写入 L1(~5ms)
│ 未命中
▼
L3: sharp 实时生成 ── 生成 400px 缩略图 + 写入 L1 + L2(~30ms)
- L1 用
lru-cache,默认 500MB 上限 - L2 存在用户数据目录下,按原图路径 hash 命名
- 缩略图固定 400px 宽,JPEG quality 60,单张约 15-30KB
配合 react-virtuoso 的虚拟滚动,只渲染可视区域内的图片卡片。
数据库设计
14 张表,核心关系:
folders ──┐
├── photos ──┬── exif_data (1:1)
│ ├── photo_tags ── tags
│ ├── face_vectors ── face_identity_members ── face_identities
│ ├── duplicate_pairs
│ ├── album_photos ── albums
│ └── cloud_sync_log ── cloud_configs
│
└── app_settings
几点设计选择:
- 软删除:photos.deletedAt 替代真删除,支持回收站
- isAiProcessed flag:追踪 AI 嵌入状态,支持断点续跑和 crash 恢复
- duplicate_pairs 的 unique index:
(photo_a_id, photo_b_id)保证同一对只存一次,且 a_id < b_id 避免重复
// schema.ts 示例 — Drizzle ORM
export const duplicatePairs = sqliteTable('duplicate_pairs', {
id: integer('id').primaryKey({ autoIncrement: true }),
photoAId: integer('photo_a_id').references(() => photos.id, { onDelete: 'cascade' }).notNull(),
photoBId: integer('photo_b_id').references(() => photos.id, { onDelete: 'cascade' }).notNull(),
matchType: text('match_type').notNull(), // 'exact' | 'phash' | 'clip_confirmed'
phashDistance: integer('phash_distance'),
clipSimilarity: real('clip_similarity'),
status: text('status').notNull().default('pending'),
}, (table) => ({
uniquePair: uniqueIndex('idx_dup_pair').on(table.photoAId, table.photoBId),
}))
踩过的坑
1. ONNX Runtime WASM 在 main process 中的 GLib 冲突
@xenova/transformers 默认用 WASM 后端,在 Electron main process 中会和 Node.js 的 GLib 产生冲突导致 crash。
解决:AI 推理全部移到 child_process.fork() 的子进程中跑,子进程用 ONNX Runtime 的 native 后端(onnxruntime-node),不走 WASM,没有 GLib 依赖。
2. sharp 和 Transformers.js 的 native 模块共存
两个库都依赖原生 .node 模块,在同一进程加载时偶尔符号冲突。
解决:AI Worker 进程和主进程完全隔离。主进程只跑 sharp(做缩略图和转码),Worker 进程只跑 Transformers.js。
3. LanceDB 的 Arrow 向量格式
LanceDB 查询返回的是 Apache Arrow 格式,vector 字段可能是 Float32Array、Array 或 Arrow Vector 对象,取决于查询方式。
解决:在读取向量时做防御性类型判断:
let vec: number[]
if (Array.isArray(rawVec)) {
vec = rawVec as number[]
} else if (typeof (rawVec as any).toArray === 'function') {
vec = Array.from((rawVec as any).toArray())
} else if (ArrayBuffer.isView(rawVec)) {
vec = Array.from(rawVec as Float32Array)
}
4. Electron 打包时 ASAR 与 native 模块
Electron Forge 默认把 node_modules 打进 app.asar,但 native 模块(.node文件)和 Transformers.js 的模型文件必须解压到文件系统。
解决:forge.config.ts 中配置 plugin-auto-unpack-natives,同时在 package.json 的 postinstall 中用 electron-rebuild 重编译 native 模块。
已知局限 & 后续计划
- 仅 Windows:Electron 本身跨平台,但打包和测试目前只做了 Windows。macOS/Linux 适配在计划中
- CLIP 模型:ViT-B/32 的中文理解靠翻译桥接,后续换 multilingual CLIP(如
clip-ViT-B-32-multilingual-v1) - GPU 推理:目前纯 CPU,ONNX Runtime 支持 CUDA/DirectML 但配置复杂,优先级不高
- 移动端:不在计划内,桌面端先做好
项目信息
- GitHub:github.com/Uyoung666/a…
- 协议:MIT(完全开源免费)
- 下载:Release 页面有安装版和便携版
- 平台:Windows 10/11
如果觉得有点意思,欢迎 Star & PR,有反馈才有动力继续迭代。
本文发布于掘金,作者:Uyoung