Về trang chủ
·Lưu Hải Long··

18 phút đọc

Tự host Google Photos trong 2 ngày: quản lý ảnh bằng AI, 100% chạy trên máy

Tự host thay thế Google Photos trong 2 ngày, 100% local: nhận diện khuôn mặt, tìm ảnh bằng AI, OCR tiếng Việt, code mở MIT, số liệu thật.

Trong bài này

Gán nhãn một khuôn mặt, nhận về 96 tấm ảnh — trên chiếc Mac không GPU

Ngày 12/07/2026, tôi gán nhãn đúng một khuôn mặt trong kho ảnh của mình rồi bấm “Tìm thêm ảnh”. Vài giây sau: 96 tấm ảnh của cùng một người hiện ra — trên chiếc MacBook Intel 2019 không GPU (chip xử lý đồ hoạ), không cloud, không tấm ảnh nào rời khỏi máy. Ý nghĩ đầu tiên không phải “AI giỏi thật”, mà là: Google và Apple đã làm trò này từ nhiều năm trước, trên chính ảnh của tôi — họ chỉ chưa bao giờ đưa tôi chìa khoá.

Khởi đầu là một cơn thèm rất cụ thể: tôi muốn mục People của iOS Photos, nhưng của riêng tôi — từng khuôn mặt, từng embedding (chuỗi số đóng vai trò “vân tay” của một gương mặt) nằm trong Postgres trên máy tôi, nơi tôi SELECT được, JOIN được. Tôi mở Claude Code và bắt đầu xây. Khoảng 2 ngày sau, ý tưởng thành hệ chạy thật, code mở giấy phép MIT. Tôi đặt tên nó là photolore; tagline cũng là lời hứa của cả bài: “Your photos, telling their own story — 100% local.”

Và 96 mới là màn chào sân: index (quét và phân tích) xong toàn bộ kho, cùng khuôn mặt đó — 1.763 tấm, không cần gán thêm nhãn nào.

34 người thân được nhận ra từ 10.599 ảnh — gán nhãn vài mặt, hệ tự tìm phần còn lại

Mặt người thân trong các ảnh chụp màn hình đã được làm mờ — bằng chính photolore. Chuyện đó, cuối bài tôi kể.

Ảnh của bạn đã được AI đọc từ lâu — chỉ là không phải bởi bạn

Trước khi viết dòng code đầu tiên, tôi làm một việc hơi tọc mạch: mở hai file SQLite nội bộ của app Ảnh — Photos.sqlite và psi.sqlite.

Tôi vẫn tưởng app Ảnh là cái tủ cất ảnh. Không — nó là phòng phân tích âm thầm suốt nhiều năm: 16.889 khuôn mặt đã phát hiện, 5.590 cụm người đã gom, hàng trăm nghìn dòng phân loại cảnh, 13.312 ảnh đã đọc chữ bên trong. Không một dòng thông báo.

Công bằng mà nói, Apple chạy tất cả ngay trên máy — tử tế về kỹ thuật. Cái gợn: trí tuệ đó bị khoá. Embedding khuôn mặt nằm trong blob 602 byte định dạng riêng — tả chính mặt người thân tôi mà tôi không dùng lại được. Kho ảnh là cơ sở dữ liệu chi tiết nhất về đời tôi — ai xuất hiện, ở đâu, lúc nào — nhưng quyền truy vấn không thuộc về tôi.

Đây không chỉ là nỗi ức chế của dân kỹ thuật: máy đã đọc mặt con bạn từ lâu — câu hỏi chỉ là ai được xem kết quả. Nghị định 13/2023/NĐ-CP xếp dữ liệu sinh trắc học vào nhóm nhạy cảm; khuôn mặt trẻ em nằm gọn trong đó. Muốn kiểm soát, chỉ còn local-first: mọi phân tích chạy trên máy mình, ảnh không rời máy.

Họ giữ chìa khoá của họ. Còn mình tự đánh lấy một chiếc được không?

Tự host trong 2 ngày: 4 bước từ ảnh thô đến nhận diện khuôn mặt local

“2 ngày” nghe như tiêu đề khoá học làm giàu, nhưng đúng thế thật: một mình tôi, chiếc Mac không GPU, Claude Code và một rổ model mở. Việc của tôi hoá ra không phải gõ code, mà là ra quyết định kiến trúc — và trả giá cho vài cái sai.

Cả hệ chỉ có 4 bước:

  1. Index — quét ảnh, phát hiện mặt, trích embedding từng khuôn mặt.
  2. Enroll — gán nhãn vài mặt của một người làm tham chiếu.
  3. Assign — quét cả kho, gắn người vào mọi mặt khớp.
  4. Xem — theo người, sự kiện, bản đồ, tìm kiếm trên web UI (giao diện web).

Bước 2 và 3 là chỗ phép màu 96-tấm-ảnh xảy ra: một nhãn gán tay, cả kho tự sáng. Cảm giác ấy, không gói thuê bao nào bán được.

Nguyên liệu đầu vào: những khuôn mặt chưa ai đặt tên

Stack toàn đồ có sẵn: InsightFace buffalo_l sinh embedding ArcFace 512 chiều; PostgreSQL + pgvector với index HNSW (tìm nhanh vector giống nhau); CLIP ViT-B/32 (model mở nối ảnh với ngôn ngữ) chạy ONNX; OCR (đọc chữ trong ảnh) mượn Apple Vision sẵn trong macOS. Web UI? FastAPI và đúng 1 file HTML.

Giờ đến phần trả giá. Tôi đặt ngưỡng cosine (thước đo độ giống hai embedding) 0,35 cho bước assign — “thoáng chút cho đỡ sót”. Kết quả: mặt trẻ em bị hút nhầm vào cụm người lớn, chỉ lộ khi tôi lọc ảnh làm demo — 25/64 mặt trong bộ lọc là gán tự động với độ tương đồng dưới 0,5, đa số nhầm. Kinh nghiệm đo được: khớp thật 0,45-0,9, người lạ dưới 0,3; khoảng giữa là vùng xám, đừng cho máy tự quyết — nhất là với mặt con mình.

Quyết định tôi cãi với chính mình lâu nhất: “tìm hết ảnh của một người” KHÔNG dùng top-k qua HNSW mà quét chính xác toàn bảng theo ngưỡng (sequential scan). Vì top-k sẽ SÓT — người xuất hiện trong hàng nghìn tấm thì k bao nhiêu cho đủ? Với “tìm mọi ảnh của mẹ tôi”, sót một tấm là sai. HNSW để dành cho “tìm mặt giống mặt này”.

Hệ chạy được rồi. Chỉ còn một chuyện nhỏ: kho ảnh của tôi lúc đó… không nằm trên máy.

Chướng ngại tên là iCloud — và phát hiện 76% cứu lại 8.000 tấm ảnh

Đang đà hưng phấn thì tôi đâm sầm vào tường. Máy bật iCloud “Optimize Mac Storage” từ lâu: trong 12.625 tấm ảnh chỉ 2.554 tấm còn bản gốc trên máy, số còn lại là vỏ gắn icon đám mây. Hai lối thoát đều dở: tải cả kho về — đầy ổ cứng, mất cả ngày; hoặc chấp nhận hệ chỉ thấy một phần năm cuộc đời mình.

Tôi chọn lối thứ ba: soi thẳng vào ruột thư viện Photos. Phát hiện đắt giá nhất của dự án nằm đó: 76% ảnh iCloud-only vẫn còn bản derivative (bản JPEG thu nhỏ cỡ ~1000px app Ảnh tự tạo để hiện preview) ngay trên đĩa. Với nhận mặt và CLIP, 1000px là thừa đủ. Nghĩa là: thêm khoảng 8.000 tấm vào index mà không tải về một byte nào.

Pipeline (chuỗi bước xử lý tự động) 7 bước chạy incremental — chỉ nạp phần mới; full-run thêm 7.995 ảnh trong 1 giờ 25 phút, 0 lỗi. Kho lớn để qua đêm, có resume — dừng rồi chạy tiếp không mất công cũ. Kèm một học phí nhỏ: khởi động job index rồi hồn nhiên đóng terminal — quay lại, job đã chết theo phiên. Bài vỡ lòng của dân vận hành: nohup ... & disown.

Đây cũng là chỗ đồ tự viết thắng tên tuổi self-host: ImmichPhotoPrism đều tốt, nhưng bắt import file vào server của chúng — gặp kho iCloud-only kiểu này là chỉ thấy vỏ. Đọc derivative tại chỗ là kiểu lách chỉ dành cho người tự cầm code.

Kho ảnh tự xếp thành từng chuyến đi: 2.345 sự kiện trên một tấm bản đồ

Kho đã đầy trở lại. Tôi mở tab Sự kiện — và ngồi lặng đi một lúc. Chuyến đi đã quên sạch, buổi cà phê chẳng nhớ vì sao có ảnh — tất cả tự xếp thành từng ô: nơi chốn, khoảng ngày, và ai có mặt hôm đó. Tôi không tạo album nào; hệ chỉ nhìn khoảng trống thời gian — chụp dồn dập rồi im bặt vài tiếng, vài ngày: chỗ im bặt ấy là ranh giới một “chương” đời.

Mỗi chuyến đi tự gom thành một sự kiện: nơi chốn, ngày, và ai có mặt

Kho của tôi thành 2.345 sự kiện, 100% có mô tả tiếng Việt tự sinh. Chi tiết tôi khoái nhất: 6.824 ảnh có tên địa danh mà không gọi một request mạng nào — Apple đã reverse-geocode (đổi toạ độ GPS thành tên nơi chốn) sẵn, cất trong Photos.sqlite ngay trên máy; xưa nay chỉ app Ảnh được đọc, lần này tôi đọc.

Cả kho ảnh trải lên bản đồ — zoom mức thế giới

1.286 sự kiện có toạ độ hiện lên bản đồ. Zoom ra mức thế giới, đời mình hoá thành chùm chấm. Tôi cuộn nó như lật cuốn nhật ký chưa từng ngồi viết — và không nằm trên máy chủ của ai.

Tìm ảnh bằng AI ngay trên máy: gõ ‘birthday cake’, tìm ‘cà phê’ không dấu

Gõ một câu tiếng Anh, CLIP lôi ra đúng khung cảnh

Nhận mặt xong vẫn còn một câu hỏi rất cũ: “cái ảnh bánh sinh nhật năm nào ấy nhỉ?” Không nhớ của ai, ở đâu — lọc theo người hay sự kiện đều bó tay. Nên tôi cho CLIP quét hết kho: 10.599/10.599 ảnh có embedding. Gõ “birthday cake”, mọi chiếc bánh tôi từng chụp hiện ra, rải khắp các năm.

Nói thẳng chỗ yếu kẻo bạn tưởng tôi bán hàng: CLIP bản gốc chỉ hiểu tiếng Anh — muốn gõ “bánh sinh nhật” phải thay model đa ngôn ngữ, tôi chưa làm.

Gõ "birthday cake", CLIP local trả về mọi chiếc bánh — không ảnh nào rời máy

OCR tiếng Việt: chữ trong ảnh cũng tìm được, không dấu vẫn khớp

Phần chữ thì ngược lại, tiếng Việt là sân nhà: Apple Vision sẵn trong macOS, không tốn thêm đồng nào. Quét đủ 10.599 ảnh, 5.277 ảnh có chữ — đúng một nửa kho. Menu quán, biển hiệu, ảnh chụp màn hình… thành text tìm được hết; gõ “ca phe” không dấu vẫn khớp “cà phê”. Mặt trái: biên lai, tin nhắn chụp màn hình cũng thành text trong database. May là database ĐÓ nằm trên máy tôi.

OCR tiếng Việt: tìm "cà phê" — gõ không dấu vẫn khớp

Rồi đến khoảnh khắc tôi thích nhất cả dự án: bấm một ảnh bất kỳ, hệ tự kể — “Ảnh chụp lúc 07:40 ngày 14/05/2024 tại Lang. Trong ảnh có Long. Khung cảnh: selfie.” Cái tagline đặt cho vui, hoá ra thành hình thật, từng ảnh một.

Bấm một ảnh, hệ tự kể: chụp lúc nào, ở đâu, có ai, thuộc sự kiện gì

Google Photos cũng kể được những chuyện này — nhưng trong app của họ, theo luật của họ. Ở đây, mọi lời kể nằm trong Postgres của tôi: muốn hỏi gì cứ viết SQL.

Đối đầu bộ não Apple Photos trên cùng 10.599 tấm ảnh

Xây xong, tôi không nhịn được: cho hệ hai ngày tuổi đấu với bộ não Apple nuôi nhiều năm. Trọng tài công bằng là 10.599 tấm ảnh chung; tôi viết script đối chiếu từng ảnh — Apple bảo có ai, photolore bảo có ai.

Hai hệ đồng thuận 5.582 lượt người-trong-ảnh. Phần vênh mới đáng tiền: photolore tìm thêm 1.514 lượt Apple bỏ qua — đa số độ tương đồng 0,5-0,7, khớp chắc chứ không vơ bừa. Chiều ngược lại, Apple thêm 592 lượt, cộng 858 lượt độc quyền trên ảnh chưa tải về — vùng photolore không với tới.

Kết luận sau cả bảng số: Apple cực kỳ bảo thủ — gần như chỉ gán khi chính bạn xác nhận tay trong app; hệ tự xây, chỉ cần đưa vài mặt tham chiếu, phủ rộng hơn hẳn. Và phải ghi nhận: trên vùng ảnh chưa tải về, Apple vẫn thắng.

Trận này còn tặng tôi một cú vả: web UI hiện một khuôn mặt không tồn tại trong database. Ma? Không — hai server hai database dùng chung một thư mục cache (bộ đệm) ảnh mặt, key cache thiếu định danh DB nên server này mượn mặt của server kia. Fix một dòng; vì sao mình tin cache vô điều kiện mới là câu đáng ngẫm.

Giới hạn thật, ai nên dùng gì — và ba bước cho tối nay

Trước khi bạn clone repo, tôi nợ bạn danh sách những thứ photolore chưa làm được:

  1. CLIP bản gốc chỉ hiểu tiếng Anh. Muốn gõ tiếng Việt phải thay model đa ngôn ngữ.
  2. CPU Intel chậm với ảnh gốc full-res. Trên máy tôi: index mặt ~1 ảnh/giây (ảnh gốc), ~3,4 ảnh/giây (derivative); CLIP ~15 ảnh/giây; OCR ~3,7-4,5 ảnh/giây. Kho 50k+ ảnh thì index là bước lâu nhất.
  3. Ngưỡng nhận mặt phải tinh chỉnh theo kho thật. Không có con số vàng — vụ 0,35 hút nhầm mặt trẻ em là vết sẹo còn mới.
  4. Chưa có app mobile, backup điện thoại, multi-user. Cần thì Immich hay PhotoPrism phù hợp hơn.
  5. OCR text nhạy cảm nằm nguyên trong DB local. Chia sẻ web UI cho người khác phải cân nhắc — dữ liệu sinh trắc của người thân vẫn nhạy cảm theo Nghị định 13/2023, kể cả khi nằm trong Postgres của bạn.

Còn ghi chú dưới tấm ảnh đầu bài? Trả nợ đây: mặt người thân trong mọi screenshot được che bằng chính photolore — detect mặt, so embedding với mặt tôi: mặt tôi giữ rõ, còn lại làm mờ; xong rà tay thêm một vòng che QR code và giấy tờ. Công cụ tự làm demo cho chính nó.

Ba bước cho tối nay, nếu bạn là dân kỹ thuật:

  1. Mở github.com/hailongluu/photolore (MIT, v0.3.0), clone về, trỏ vào một thư mục vài trăm ảnh thôi — đừng dí cả kho ngay.
  2. Chạy index → enroll đúng 1 khuôn mặt → assign. Rồi chờ khoảnh khắc 96-ảnh-trong-vài-giây của riêng bạn.
  3. Vướng gì mở issue. Thấy hợp, để lại một ngôi sao.

Nếu bạn không code: tối nay mở app ảnh, vào mục People, xem nó đã biết gì về gia đình mình — và biết từ bao giờ. Local-first là lựa chọn có thật, không phải đặc quyền của dân kỹ thuật.

Nỗi băn khoăn về ảnh con mình không tan bằng cách xoá app — nó tan khi bạn tự cầm chìa khoá.

Hỏi nhanh — đáp gọn (FAQ)

photolore có gửi ảnh của tôi lên mạng không?
Không. Nhận diện mặt, CLIP, OCR đều chạy trên máy; ảnh và embedding nằm trong PostgreSQL local. Code mở MIT — kiểm tra được từng dòng.

Nên chọn photolore hay Immich/PhotoPrism?
Cần app mobile, backup điện thoại, multi-user: Immich hoặc PhotoPrism. Muốn dữ liệu nằm trong Postgres của mình, index kho iCloud “Optimize Storage” không cần tải ảnh về: photolore.

Tìm ảnh bằng tiếng Việt được không?
Tìm chữ trong ảnh (OCR) thì được, không dấu vẫn khớp. Tìm theo khung cảnh (CLIP) hiện chỉ hiểu tiếng Anh.


Bài này cũng đăng ở news.luuhailong.com.

Số liệu trong bài do tôi tự đo trên kho ảnh cá nhân ngày 13/07/2026; ảnh chụp màn hình là hệ thật đang chạy. Dự án photolore mã nguồn mở, giấy phép MIT.


Tác giả: Lưu Hải Long. Tìm hiểu về tác giả →