一个人用 Electron + React + Transformers.js 做了个离线 AI 图片管理器,聊聊架构设计和踩坑经验

24 阅读9分钟

导语

Windows 上的照片管理工具不少,但能在本地跑 AI 语义搜索、人脸识别、智能去重的桌面应用不多。本文分享一个从零搭建的开源项目,重点聊聊技术选型、架构设计和 AI 推理在 Electron 中的落地经验。


为什么又造了个轮子

市面上现有的方案各有各的痛点:

  • 看图软件(ImageGlass/qimgv):看图行,管理为零
  • Eagle:设计素材管理神器,但会拷贝文件打乱原有目录结构,且收费
  • Immich/PhotoPrism:功能强但要部署 Docker 服务端,不是双击即用的桌面应用
  • Lap:今年很火的 Rust + Tauri 方案,轻量但功能相对基础

我想要的是:双击 exe 打开,直接索引现有文件夹,AI 搜索/去重/标签/人脸识别全打包,完全离线。 没有现成的,就自己写。

项目地址:github.com/Uyoung666/a…,MIT 协议,欢迎 Star & PR。

首页.png

预览.png

照片详情.png

重复照片检测.png

快捷键.png

技术栈

选型选型理由
桌面框架Electron 41生态成熟,React/shadcn/ui 直接复用
前端React 19 + TypeScript 6 strict类型安全,strict 模式避免低级错误
UIshadcn/ui + Radix UI + Tailwind CSS 4组件质量高,暗色模式开箱即用
构建Vite 8HMR 秒级热更新
IPCoRPC全类型安全的 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://,好处是:

  1. 安全白名单:只有用户添加到索引的文件夹路径才能通过协议读取,随便输入一个系统文件路径返回 403
  2. 实时转码:遇到 TIFF/HEIC/RAW 等浏览器不支持的格式,sharp 自动转 PNG 返回
  3. 永久缓存:响应头设了 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 字段可能是 Float32ArrayArray 或 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.jsonpostinstall 中用 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 但配置复杂,优先级不高
  • 移动端:不在计划内,桌面端先做好

项目信息

  • GitHubgithub.com/Uyoung666/a…
  • 协议:MIT(完全开源免费)
  • 下载:Release 页面有安装版和便携版
  • 平台:Windows 10/11

如果觉得有点意思,欢迎 Star & PR,有反馈才有动力继续迭代。


本文发布于掘金,作者:Uyoung