Compare commits

...

10 Commits

Author SHA1 Message Date
f60f66d6e2 chore: service tweaks 2026-03-26 08:39:03 +02:00
0fcb94e0b8 Replace remote ML API with local ONNX inference, add person image counts and gallery persons
- Remove MlClient HTTP client and reqwest dependency; all inference now runs
  locally via rbv-infer (CLIP visual+textual, SCRFD, ArcFace)
- Make --model-dir required, remove --ml-uri from CLI and API server
- Add denormalized image_count column to persons table with trigger to keep
  it updated; order /people route by image count descending
- Add GET /galleries/:id/persons endpoint and display person links on gallery page
- Fix face crop endpoint to resize source image to match detection coordinates
- Add --ml-purge flag to index command for wiping all ML data before re-index
- Add --face-score-thresh CLI arg (default 0.7, was hardcoded 0.5) to tune
  face detection sensitivity
- Lower default cluster threshold from 0.65 to 0.55 for more aggressive grouping
- Include gallery ID in pipeline log output and image path in error messages
- Update docs and systemd service files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:38:24 +02:00
409c8fd86b chore: deployment semantics 2026-03-26 06:18:40 +02:00
f5fa5f4f6b Batch cluster DB writes: ~268k round trips → two bulk queries
Generate all PersonIds upfront, then bulk-insert all persons with a
chunked QueryBuilder INSERT and bulk-update all face assignments with a
chunked QueryBuilder UPDATE FROM VALUES. Reduces the 40-minute write
phase to seconds. Also fixes NaiveDateTime/TIMESTAMPTZ decode in person.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:46:49 +02:00
23c3836510 Add VectorChord installation to db-cluster.md
Manual install from GitHub release zip using exact paths confirmed from
zip contents (sharedir/extension/* → /usr/pgsql-17/share/extension/,
pkglibdir/vchord.so → /usr/pgsql-17/lib/). Adds shared_preload_libraries
and CREATE EXTENSION for both rbv and immich databases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:21:20 +02:00
166e682880 Use setfacl for postgres cert access instead of copying files
Leaves certs root-owned; ACL grants postgres read access so renewals
take effect without re-copying. Use absolute paths in postgresql.conf.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:25:03 +02:00
cc0a59995a Update db-cluster.md: cert auth throughout, no passwords
Replace scram-sha-256 with mTLS cert auth using the step CA convention
already in use on all infra hosts. Documents cert installation, pg_hba
hostssl/cert rules, pg_ident CN mapping, passwordless role creation,
and cert-auth connection strings for rbv and immich.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:22:30 +02:00
2c3192fea9 Add doc/db-cluster.md: standalone setup for frankie + Patroni HA plan
Documents Phase 1 (PG17 + pgvector standalone on frankie.hanzalova.internal)
and outlines Phase 2 (Patroni three-node cluster) for future reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:13:45 +02:00
caf60c0ff0 Fix cluster DB pool size to 2 (sequential access, not concurrency-bound)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:03:08 +02:00
81a9044870 Add --concurrency to cluster command, parallelise dot products with rayon
Mirrors the index command convention: defaults to 4, tunable per host in
the service unit. The inner similarity loop is parallelised via a rayon
thread pool of the configured size; union-find remains sequential.
rayon added to workspace and rbv-cluster dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:58:09 +02:00
37 changed files with 702 additions and 688 deletions

3
.gitignore vendored
View File

@@ -17,7 +17,6 @@
/dist/
# Editor
.idea/
.vscode/
.zed/
*.swp
*~

295
Cargo.lock generated
View File

@@ -430,12 +430,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -1061,10 +1055,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1074,11 +1066,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1282,24 +1272,6 @@ dependencies = [
"pin-utils",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.6",
]
[[package]]
@@ -1308,21 +1280,13 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -1504,22 +1468,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1637,12 +1585,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.15.7"
@@ -2236,61 +2178,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2480,6 +2367,7 @@ dependencies = [
name = "rbv-cluster"
version = "0.1.0"
dependencies = [
"rayon",
"rbv-entity",
"thiserror 2.0.18",
"tracing",
@@ -2560,11 +2448,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"rbv-entity",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
]
[[package]]
@@ -2627,46 +2511,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2701,12 +2545,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rusticata-macros"
version = "4.1.0"
@@ -2760,7 +2598,6 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
@@ -3277,9 +3114,6 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -3550,14 +3384,12 @@ dependencies = [
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
@@ -3650,12 +3482,6 @@ dependencies = [
"tracing-serde",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -3818,15 +3644,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3870,20 +3687,6 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
@@ -3950,26 +3753,6 @@ dependencies = [
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
@@ -4112,15 +3895,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4154,30 +3928,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -4190,12 +3947,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -4208,12 +3959,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -4226,24 +3971,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -4256,12 +3989,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -4274,12 +4001,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -4292,12 +4013,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -4310,12 +4025,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -27,9 +27,6 @@ serde_json = "1"
# Async runtime
tokio = { version = "1", features = ["full"] }
# HTTP client (for ML API)
reqwest = { version = "0.12", default-features = false, features = ["multipart", "json", "rustls-tls"] }
# Web framework
axum = { version = "0.8", features = ["macros"] }
tower = "0.5"
@@ -64,6 +61,7 @@ anyhow = "1"
async-trait = "0.1"
glob = "0.3"
rand = "0.8"
rayon = "1"
hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "http1", "http2"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }

View File

@@ -1,10 +1,10 @@
# rbv
Personal photo library indexer with facial recognition and semantic search.
Extracts CLIP embeddings and face detections from image galleries via an
[immich-ml](https://github.com/immich-app/immich) compatible ML API, clusters
faces into person identities, and serves results through a mTLS-authenticated
HTTPS API with a React web UI.
Extracts CLIP embeddings and face detections from image galleries using local
ONNX inference (ViT-B-32 CLIP + SCRFD/ArcFace), clusters faces into person
identities, and serves results through a mTLS-authenticated HTTPS API with a
React web UI.
## Workspace layout
@@ -15,7 +15,8 @@ crates/
rbv-entity shared type definitions (no logic)
rbv-hash BLAKE3 content-addressed ID generation
rbv-data database access layer (sqlx + pgvector)
rbv-ml ML API client (immich-ml wire format)
rbv-ml ML backend trait and shared types
rbv-infer local ONNX inference (CLIP, SCRFD, ArcFace)
rbv-cluster face embedding clustering (DBSCAN / union-find)
rbv-ingest gallery discovery and ingest pipeline
rbv-auth mTLS validation, argon2 passwords, sessions
@@ -43,7 +44,7 @@ rbv-api → rbv-entity, rbv-data, rbv-ml, rbv-auth, rbv-search
## Prerequisites
- PostgreSQL with the `pgvector` extension
- An immich-ml compatible ML API (e.g. the immich `machine-learning` container)
- ONNX model files (ViT-B-32 CLIP + buffalo_l face models)
- Rust toolchain (stable)
- Node.js + npm (for the UI)
@@ -82,7 +83,7 @@ rbv-api \
--server-cert /etc/rbv/server.pem \
--server-key /etc/rbv/server.key \
--database "$DATABASE_URL" \
--ml-uri http://127.0.0.1:3003 \
--model-dir /path/to/models \
--ui-dir /srv/rbv/ui \
--face-cache /srv/rbv/cache/faces \
[--listen 0.0.0.0:8443] \

View File

@@ -3,7 +3,7 @@ server {
server_name rbv.internal;
http2 on;
ssl_certificate /etc/nginx/tls/rbv/chain.pem;
ssl_certificate /etc/nginx/tls/rbv/rbv.pem;
ssl_certificate_key /etc/nginx/tls/rbv/key.pem;
root /usr/share/nginx/rbv;

View File

@@ -8,7 +8,7 @@ ExecStart=/usr/local/bin/rbv-api \
--server-cert /etc/nginx/tls/rbv/rbv.pem \
--server-key /etc/nginx/tls/rbv/key.pem \
--database postgres://rbv:password@localhost:4432/rbv \
--ml-uri http://127.0.0.1:3003
--model-dir /tank/containers/immich/ml-cache
Restart=always
[Install]

View File

@@ -7,7 +7,9 @@ ConditionFileIsExecutable=/usr/local/bin/rbv
Type=oneshot
Environment=RUST_LOG=info,ort=off,sqlx::query=off
ExecStart=/usr/local/bin/rbv cluster \
--database postgres://rbv:password@localhost:4432/rbv
--database postgres://rbv:password@localhost:4432/rbv \
--concurrency 32 \
--threshold 0.55
[Install]
WantedBy=multi-user.target

View File

@@ -8,9 +8,10 @@ OnSuccess=rbv-cluster.service
Environment=RUST_LOG=info,ort=off,sqlx::query=off
ExecStart=/usr/local/bin/rbv index \
--target /tank/data/rbv/%i \
--concurrency 24 \
--concurrency 32 \
--database postgres://rbv:password@localhost:4432/rbv \
--model-dir /tank/containers/immich/ml-cache
--model-dir /tank/containers/immich/ml-cache \
--face-score-thresh 0.7
Restart=always
[Install]

12
asset/systemd/step@.timer Normal file
View File

@@ -0,0 +1,12 @@
[Unit]
Description=step cert renew
Documentation=https://hackmd.io/@rob-tn/rJvy9YYKWg
[Timer]
Persistent=true
OnCalendar=*:1/15
AccuracySec=1us
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target

View File

@@ -24,13 +24,13 @@ pub struct ApiArgs {
#[arg(long)]
pub database: String,
/// Base URL of the machine learning API (mutually exclusive with --model-dir)
#[arg(long, conflicts_with = "model_dir")]
pub ml_uri: Option<String>,
/// Path to ONNX model directory for local inference
#[arg(long)]
pub model_dir: PathBuf,
/// Path to ONNX model directory for local inference (mutually exclusive with --ml-uri)
#[arg(long, conflicts_with = "ml_uri")]
pub model_dir: Option<PathBuf>,
/// Minimum face detection confidence score (0.01.0, default 0.7)
#[arg(long)]
pub face_score_thresh: Option<f32>,
/// Address to listen on
#[arg(long, default_value = "0.0.0.0:8443")]

View File

@@ -51,10 +51,19 @@ async fn serve_face_crop(
let img = image::load_from_memory(&raw)
.map_err(|e| ApiError::internal(format!("image decode: {e}")))?;
// Bounding box coordinates are relative to the resized image (max 1280px)
// that was sent to the ML backend. Resize to match before cropping.
const MAX_DIM: u32 = 1280;
let img = if img.width() > MAX_DIM || img.height() > MAX_DIM {
img.resize(MAX_DIM, MAX_DIM, image::imageops::FilterType::Lanczos3)
} else {
img
};
let x = x1.max(0) as u32;
let y = y1.max(0) as u32;
let w = (x2 - x1).max(1) as u32;
let h = (y2 - y1).max(1) as u32;
let w = ((x2 - x1).max(1) as u32).min(img.width().saturating_sub(x));
let h = ((y2 - y1).max(1) as u32).min(img.height().saturating_sub(y));
let cropped = img.crop_imm(x, y, w, h);
let resized = cropped.resize(256, 256, image::imageops::FilterType::Lanczos3);

View File

@@ -1,7 +1,7 @@
use axum::{extract::{Path, Query, State}, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use rbv_entity::Gallery;
use crate::{error::ApiResult, state::AppState};
use crate::{error::ApiResult, routes::person::PersonResponse, state::AppState};
pub fn router() -> Router<AppState> {
Router::new()
@@ -9,6 +9,7 @@ pub fn router() -> Router<AppState> {
.route("/random", get(random_galleries))
.route("/{id}", get(get_gallery))
.route("/{id}/images", get(get_gallery_images))
.route("/{id}/persons", get(get_gallery_persons))
}
#[derive(Deserialize)]
@@ -101,3 +102,18 @@ async fn get_gallery_images(
})).collect();
Ok(Json(out))
}
async fn get_gallery_persons(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<Vec<PersonResponse>>> {
let bytes = rbv_hash::from_hex(&id)
.map_err(|_| crate::error::ApiError::bad_request("invalid gallery id"))?;
let gid = rbv_entity::GalleryId(bytes);
let persons = rbv_data::person::get_persons_for_gallery(&state.pool, &gid).await?;
let mut out = Vec::with_capacity(persons.len());
for p in &persons {
out.push(super::person::person_response(&state.pool, p, 0).await?);
}
Ok(Json(out))
}

View File

@@ -28,9 +28,10 @@ pub struct PersonResponse {
pub primary_name: Option<String>,
pub names: Vec<String>,
pub created_at: String,
pub image_count: i64,
}
async fn person_response(pool: &sqlx::PgPool, person: &Person) -> ApiResult<PersonResponse> {
pub async fn person_response(pool: &sqlx::PgPool, person: &Person, image_count: i64) -> ApiResult<PersonResponse> {
let name_rows = rbv_data::person::get_person_names(pool, &person.id).await?;
let primary = name_rows.iter().find(|n| n.is_primary).map(|n| n.name.clone());
let names: Vec<_> = name_rows.into_iter().map(|n| n.name).collect();
@@ -39,6 +40,7 @@ async fn person_response(pool: &sqlx::PgPool, person: &Person) -> ApiResult<Pers
primary_name: primary,
names,
created_at: person.created_at.to_rfc3339(),
image_count,
})
}
@@ -49,7 +51,7 @@ async fn list_persons(
let persons = rbv_data::person::get_all_persons_paged(&state.pool, q.page, q.per_page).await?;
let mut out = Vec::with_capacity(persons.len());
for p in &persons {
out.push(person_response(&state.pool, p).await?);
out.push(person_response(&state.pool, &p.person, p.image_count).await?);
}
Ok(Json(out))
}
@@ -62,7 +64,8 @@ async fn get_person(
let person = rbv_data::person::get_person(&state.pool, &pid)
.await?
.ok_or_else(|| ApiError::not_found("person not found"))?;
Ok(Json(person_response(&state.pool, &person).await?))
let image_count = rbv_data::person::count_images_for_person(&state.pool, &pid).await.unwrap_or(0);
Ok(Json(person_response(&state.pool, &person, image_count).await?))
}
#[derive(Deserialize)]

View File

@@ -3,25 +3,16 @@ use anyhow::Result;
use axum::Router;
use tower_http::services::ServeDir;
use tracing::{info, warn};
use anyhow::bail;
use rbv_ml::{MlBackend, MlClient};
use crate::{args::ApiArgs, middleware, routes, state::AppState, tls::build_rustls_config};
pub async fn run(args: ApiArgs) -> Result<()> {
let pool = rbv_data::connect(&args.database, 10).await?;
rbv_data::run_migrations(&pool).await?;
let ml: Arc<dyn MlBackend> = match (&args.model_dir, &args.ml_uri) {
(Some(model_dir), _) => {
info!("Using local ONNX inference from {}", model_dir.display());
Arc::new(rbv_infer::OnnxBackend::load(model_dir)?)
}
(_, Some(uri)) => {
info!("Using remote ML API at {uri}");
Arc::new(MlClient::new(uri))
}
(None, None) => bail!("Either --ml-uri or --model-dir must be provided"),
};
info!("Using local ONNX inference from {}", args.model_dir.display());
let ml: Arc<dyn rbv_ml::MlBackend> = Arc::new(
rbv_infer::OnnxBackend::load_with_options(&args.model_dir, args.face_score_thresh)?
);
let allowed_cns = args.client_cn.clone();
// Ensure face cache directory exists if configured.

View File

@@ -25,7 +25,7 @@ them in the database.
rbv index \
--target <PATH>... \
--database <CONNSTR> \
--ml-uri <URL> \
--model-dir <PATH> \
[--concurrency <N>] # default 4
[--include <GLOB>...]
[--exclude <GLOB>...]
@@ -39,8 +39,7 @@ rbv index \
- Any arbitrary directory — galleries are discovered recursively
Images already present in the database are skipped, so re-running against
the same target is safe and cheap. Failed images (e.g. due to a transient ML
API error) are not written to the database and will be retried on the next
the same target is safe and cheap. Failed images are not written to the database and will be retried on the next
run.
**Quality note:** Indexing one gallery or the whole tree produces identical
@@ -76,13 +75,13 @@ rbv cluster \
rbv migrate --database "$DATABASE_URL"
# 2. Index all galleries (incremental — safe to re-run)
rbv index --target /mnt/galleries --database "$DATABASE_URL" --ml-uri http://ml:3003
rbv index --target /mnt/galleries --database "$DATABASE_URL" --model-dir /path/to/models
# 3. Cluster faces into persons
rbv cluster --database "$DATABASE_URL"
# 4. As new galleries are added, repeat steps 23
rbv index --target /mnt/galleries/new-chunk --database "$DATABASE_URL" --ml-uri http://ml:3003
rbv index --target /mnt/galleries/new-chunk --database "$DATABASE_URL" --model-dir /path/to/models
rbv cluster --database "$DATABASE_URL"
```

View File

@@ -54,13 +54,9 @@ pub struct IndexArgs {
#[arg(long)]
pub database: String,
/// Base URL of the machine learning API (mutually exclusive with --model-dir)
#[arg(long, conflicts_with = "model_dir")]
pub ml_uri: Option<String>,
/// Path to ONNX model directory for local inference (mutually exclusive with --ml-uri)
#[arg(long, conflicts_with = "ml_uri")]
pub model_dir: Option<PathBuf>,
/// Path to ONNX model directory for local inference
#[arg(long)]
pub model_dir: PathBuf,
/// Number of images to process concurrently (parallelises I/O and
/// preprocessing; ONNX inference itself is serialised per model)
@@ -72,6 +68,16 @@ pub struct IndexArgs {
/// are more likely to be reached early in a run.
#[arg(long)]
pub sort_galleries: bool,
/// Minimum face detection confidence score (0.01.0, default 0.7).
/// Higher values reject low-quality faces (blurry, partial, background).
#[arg(long)]
pub face_score_thresh: Option<f32>,
/// Purge all ML-derived data (embeddings, face detections, persons)
/// before indexing, forcing a full re-index from scratch.
#[arg(long)]
pub ml_purge: bool,
}
#[derive(Parser)]
@@ -80,7 +86,12 @@ pub struct ClusterArgs {
#[arg(long)]
pub database: String,
/// Cosine similarity threshold for grouping faces (0.01.0)
#[arg(long, default_value = "0.65")]
/// Cosine similarity threshold for grouping faces (0.01.0).
/// Lower values group more aggressively (fewer persons, more merges).
#[arg(long, default_value = "0.55")]
pub threshold: f32,
/// Number of threads for parallel similarity computation
#[arg(long, default_value = "4")]
pub concurrency: usize,
}

View File

@@ -4,7 +4,7 @@ use rbv_cluster::{cluster_faces, ClusterConfig};
use crate::args::ClusterArgs;
pub async fn run(args: ClusterArgs) -> Result<()> {
let pool = rbv_data::connect(&args.database, 4).await?;
let pool = rbv_data::connect(&args.database, 2).await?;
info!("Loading unassigned face embeddings...");
let faces = rbv_data::face::unassigned_face_embeddings(&pool).await?;
@@ -18,27 +18,37 @@ pub async fn run(args: ClusterArgs) -> Result<()> {
let config = ClusterConfig {
similarity_threshold: args.threshold,
min_cluster_size: 1,
concurrency: args.concurrency,
};
info!("Clustering with threshold {}...", args.threshold);
let clusters = cluster_faces(&faces, &config);
info!("Formed {} clusters.", clusters.len());
let mut persons_created = 0u64;
let mut faces_assigned = 0u64;
for cluster in clusters {
// Generate all person IDs upfront then write in two bulk queries.
let mut person_ids = Vec::with_capacity(clusters.len());
let mut assignments = Vec::new();
for cluster in &clusters {
if cluster.is_empty() {
continue;
}
let person_id = rbv_data::person::create_person(&pool).await?;
persons_created += 1;
for face_id in &cluster {
rbv_data::face::assign_face_to_person(&pool, face_id, &person_id).await?;
faces_assigned += 1;
let person_id = rbv_entity::PersonId::new();
for face_id in cluster {
assignments.push((face_id.clone(), person_id.clone()));
}
person_ids.push(person_id);
}
info!("Created {} persons, assigned {} faces.", persons_created, faces_assigned);
let persons_created = person_ids.len();
let faces_assigned = assignments.len();
info!("Writing {persons_created} persons and {faces_assigned} face assignments...");
rbv_data::person::create_persons_batch(&pool, &person_ids).await?;
rbv_data::face::assign_faces_batch(&pool, &assignments).await?;
info!("Refreshing person image counts...");
rbv_data::person::refresh_image_counts(&pool, &person_ids).await?;
info!("Created {persons_created} persons, assigned {faces_assigned} faces.");
Ok(())
}

View File

@@ -1,24 +1,22 @@
use anyhow::{bail, Result};
use anyhow::Result;
use tracing::info;
use std::sync::Arc;
use rbv_ingest::{IngestConfig, ingest_galleries, discover_galleries, FilterConfig};
use rbv_ml::{MlBackend, MlClient};
use crate::args::IndexArgs;
pub async fn run(args: IndexArgs) -> Result<()> {
let pool = rbv_data::connect(&args.database, args.concurrency as u32 + 4).await?;
let ml: Arc<dyn MlBackend> = match (&args.model_dir, &args.ml_uri) {
(Some(model_dir), _) => {
info!("Using local ONNX inference from {}", model_dir.display());
Arc::new(rbv_infer::OnnxBackend::load(model_dir)?)
}
(_, Some(uri)) => {
info!("Using remote ML API at {uri}");
Arc::new(MlClient::new(uri))
}
(None, None) => bail!("Either --ml-uri or --model-dir must be provided"),
};
if args.ml_purge {
info!("Purging all ML-derived data (embeddings, faces, persons)...");
rbv_data::image::purge_ml_data(&pool).await?;
info!("Purge complete.");
}
info!("Using local ONNX inference from {}", args.model_dir.display());
let ml: Arc<dyn rbv_ml::MlBackend> = Arc::new(
rbv_infer::OnnxBackend::load_with_options(&args.model_dir, args.face_score_thresh)?
);
let filter = FilterConfig::new(args.include, args.exclude);
let config = IngestConfig {

View File

@@ -8,3 +8,4 @@ license.workspace = true
rbv-entity = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
rayon = { workspace = true }

View File

@@ -1,4 +1,5 @@
use rbv_entity::FaceId;
use rayon::prelude::*;
use tracing::info;
pub struct ClusterConfig {
@@ -6,6 +7,8 @@ pub struct ClusterConfig {
pub similarity_threshold: f32,
/// Minimum number of faces to form a cluster (1 = all faces get a person ID).
pub min_cluster_size: usize,
/// Number of threads for parallel similarity computation.
pub concurrency: usize,
}
impl Default for ClusterConfig {
@@ -13,6 +16,7 @@ impl Default for ClusterConfig {
Self {
similarity_threshold: 0.65,
min_cluster_size: 1,
concurrency: 4,
}
}
}
@@ -33,19 +37,28 @@ pub fn cluster_faces(faces: &[(FaceId, Vec<f32>)], config: &ClusterConfig) -> Ve
// Normalise embeddings to unit length for cosine similarity via dot product.
let normalised: Vec<Vec<f32>> = faces.iter().map(|(_, e)| normalise(e)).collect();
// Union-Find
// Union-Find — dot products are parallelised per row, union-find stays sequential.
let mut parent: Vec<usize> = (0..n).collect();
let mut unions = 0usize;
let log_every = (n / 100).max(1);
let started = std::time::Instant::now();
let threshold = config.similarity_threshold;
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(config.concurrency)
.build()
.expect("failed to build rayon thread pool");
for i in 0..n {
for j in (i + 1)..n {
let sim = dot(&normalised[i], &normalised[j]);
if sim >= config.similarity_threshold {
if union(&mut parent, i, j) {
unions += 1;
}
let matches: Vec<usize> = pool.install(|| {
(i + 1..n)
.into_par_iter()
.filter(|&j| dot(&normalised[i], &normalised[j]) >= threshold)
.collect()
});
for j in matches {
if union(&mut parent, i, j) {
unions += 1;
}
}

View File

@@ -38,6 +38,32 @@ pub async fn assign_face_to_person(pool: &PgPool, face_id: &FaceId, person_id: &
Ok(())
}
pub async fn assign_faces_batch(pool: &PgPool, assignments: &[(FaceId, PersonId)]) -> Result<()> {
if assignments.is_empty() {
return Ok(());
}
// Disable the per-row image_count trigger during bulk assignment;
// caller should use person::refresh_all_image_counts() afterwards.
sqlx::query("ALTER TABLE face_detections DISABLE TRIGGER trg_fd_person_image_count")
.execute(pool).await?;
const CHUNK: usize = 16_000;
for chunk in assignments.chunks(CHUNK) {
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"UPDATE face_detections SET person_id = v.person_id \
FROM (VALUES ",
);
qb.push_values(chunk, |mut b, (face_id, person_id)| {
b.push_bind(face_id.as_bytes().to_vec())
.push_bind(person_id.as_uuid());
});
qb.push(") AS v(face_id, person_id) WHERE face_detections.id = v.face_id");
qb.build().execute(pool).await?;
}
sqlx::query("ALTER TABLE face_detections ENABLE TRIGGER trg_fd_person_image_count")
.execute(pool).await?;
Ok(())
}
pub async fn all_face_embeddings(pool: &PgPool) -> Result<Vec<(FaceId, Vec<f32>)>> {
let rows = sqlx::query("SELECT id, embedding FROM face_detections")
.fetch_all(pool)

View File

@@ -2,6 +2,22 @@ use anyhow::Result;
use sqlx::{PgPool, Postgres, Row};
use rbv_entity::{GalleryId, GalleryImage, Image, ImageId};
/// Purge all ML-derived data: persons, face detections, CLIP embeddings,
/// and image records. Gallery metadata is preserved. Cascading FKs handle
/// the dependent tables (person_names, gallery_images, etc).
pub async fn purge_ml_data(pool: &PgPool) -> Result<()> {
// Disable the image_count trigger to avoid per-row overhead during truncate.
sqlx::query("ALTER TABLE face_detections DISABLE TRIGGER trg_fd_person_image_count")
.execute(pool).await?;
sqlx::query("TRUNCATE persons CASCADE").execute(pool).await?;
sqlx::query("TRUNCATE face_detections CASCADE").execute(pool).await?;
sqlx::query("TRUNCATE clip_embeddings").execute(pool).await?;
sqlx::query("TRUNCATE gallery_images, images CASCADE").execute(pool).await?;
sqlx::query("ALTER TABLE face_detections ENABLE TRIGGER trg_fd_person_image_count")
.execute(pool).await?;
Ok(())
}
pub async fn image_exists(pool: &PgPool, id: &ImageId) -> Result<bool> {
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM images WHERE id = $1)")
.bind(id.as_bytes())

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use sqlx::{PgPool, Row};
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
use rbv_entity::{GalleryId, Person, PersonId, PersonName};
pub async fn create_person(pool: &PgPool) -> Result<PersonId> {
@@ -11,6 +11,21 @@ pub async fn create_person(pool: &PgPool) -> Result<PersonId> {
Ok(id)
}
pub async fn create_persons_batch(pool: &PgPool, person_ids: &[PersonId]) -> Result<()> {
if person_ids.is_empty() {
return Ok(());
}
const CHUNK: usize = 32_000;
for chunk in person_ids.chunks(CHUNK) {
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("INSERT INTO persons (id) ");
qb.push_values(chunk, |mut b, id| {
b.push_bind(id.as_uuid());
});
qb.build().execute(pool).await?;
}
Ok(())
}
pub async fn get_person(pool: &PgPool, id: &PersonId) -> Result<Option<Person>> {
let row = sqlx::query("SELECT id, created_at FROM persons WHERE id = $1")
.bind(id.as_uuid())
@@ -18,21 +33,29 @@ pub async fn get_person(pool: &PgPool, id: &PersonId) -> Result<Option<Person>>
.await?;
Ok(row.map(|r| Person {
id: PersonId(r.get("id")),
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
created_at: r.get("created_at"),
}))
}
pub async fn get_all_persons_paged(pool: &PgPool, page: i64, per_page: i64) -> Result<Vec<Person>> {
pub struct PersonWithImageCount {
pub person: Person,
pub image_count: i64,
}
pub async fn get_all_persons_paged(pool: &PgPool, page: i64, per_page: i64) -> Result<Vec<PersonWithImageCount>> {
let rows = sqlx::query(
"SELECT id, created_at FROM persons ORDER BY created_at LIMIT $1 OFFSET $2",
"SELECT id, created_at, image_count FROM persons ORDER BY image_count DESC, created_at LIMIT $1 OFFSET $2",
)
.bind(per_page)
.bind((page - 1) * per_page)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(|r| Person {
id: PersonId(r.get("id")),
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
Ok(rows.iter().map(|r| PersonWithImageCount {
person: Person {
id: PersonId(r.get("id")),
created_at: r.get("created_at"),
},
image_count: r.get("image_count"),
}).collect())
}
@@ -92,7 +115,7 @@ pub async fn find_persons_by_name(pool: &PgPool, name: &str) -> Result<Vec<(Pers
let id: uuid::Uuid = r.get("id");
let person = Person {
id: PersonId(id),
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
created_at: r.get("created_at"),
};
let pname = PersonName {
person_id: PersonId(id),
@@ -103,6 +126,47 @@ pub async fn find_persons_by_name(pool: &PgPool, name: &str) -> Result<Vec<(Pers
}).collect())
}
pub async fn count_images_for_person(pool: &PgPool, person_id: &PersonId) -> Result<i64> {
let row = sqlx::query("SELECT image_count FROM persons WHERE id = $1")
.bind(person_id.as_uuid())
.fetch_one(pool)
.await?;
Ok(row.get("image_count"))
}
/// Recompute image_count for a specific set of persons.
/// Much faster than a full-table scan when you know which persons changed.
pub async fn refresh_image_counts(pool: &PgPool, person_ids: &[PersonId]) -> Result<()> {
if person_ids.is_empty() {
return Ok(());
}
const CHUNK: usize = 32_000;
for chunk in person_ids.chunks(CHUNK) {
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
r#"
UPDATE persons p
SET image_count = COALESCE(fc.cnt, 0)
FROM (
SELECT fd.person_id, COUNT(DISTINCT fd.image_id) AS cnt
FROM face_detections fd
WHERE fd.person_id IN (
"#,
);
let mut sep = qb.separated(", ");
for id in chunk {
sep.push_bind(id.as_uuid());
}
qb.push(") GROUP BY fd.person_id) fc WHERE p.id = fc.person_id AND p.id IN (");
let mut sep = qb.separated(", ");
for id in chunk {
sep.push_bind(id.as_uuid());
}
qb.push(")");
qb.build().execute(pool).await?;
}
Ok(())
}
pub async fn count_persons(pool: &PgPool) -> Result<i64> {
let row = sqlx::query("SELECT COUNT(*) AS count FROM persons")
.fetch_one(pool)
@@ -125,6 +189,6 @@ pub async fn get_persons_for_gallery(pool: &PgPool, gallery_id: &GalleryId) -> R
.await?;
Ok(rows.iter().map(|r| Person {
id: PersonId(r.get("id")),
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
created_at: r.get("created_at"),
}).collect())
}

View File

@@ -38,7 +38,7 @@ pub struct OnnxBackend {
}
impl OnnxBackend {
/// Load from a model directory with immich-compatible layout:
/// Load from a model directory with the following layout:
/// ```text
/// <model_dir>/
/// clip/ViT-B-32__openai/visual/model.onnx
@@ -48,6 +48,10 @@ impl OnnxBackend {
/// facial-recognition/buffalo_l/recognition/model.onnx
/// ```
pub fn load(model_dir: &Path) -> Result<Self, InferError> {
Self::load_with_options(model_dir, None)
}
pub fn load_with_options(model_dir: &Path, face_score_thresh: Option<f32>) -> Result<Self, InferError> {
let clip_base = model_dir.join("clip").join("ViT-B-32__openai");
let face_base = model_dir.join("facial-recognition").join("buffalo_l");
@@ -69,8 +73,13 @@ impl OnnxBackend {
tracing::info!("Loading CLIP textual model from {}", textual_model.display());
let clip_textual = clip_textual::ClipTextual::load(&textual_model, &tokenizer_json)?;
tracing::info!("Loading SCRFD detection model from {}", detect_model.display());
let face_detect = scrfd::Scrfd::load(&detect_model)?;
let face_detect = if let Some(thresh) = face_score_thresh {
tracing::info!("Loading SCRFD detection model from {} (score_thresh={thresh})", detect_model.display());
scrfd::Scrfd::load_with_threshold(&detect_model, thresh)?
} else {
tracing::info!("Loading SCRFD detection model from {}", detect_model.display());
scrfd::Scrfd::load(&detect_model)?
};
tracing::info!("Loading ArcFace recognition model from {}", recognize_model.display());
let face_recognize = arcface::ArcFace::load(&recognize_model)?;

View File

@@ -5,7 +5,7 @@ use ort::value::Tensor;
use crate::{InferError, preprocess};
const CANVAS: u32 = 640;
const SCORE_THRESH: f32 = 0.5;
const DEFAULT_SCORE_THRESH: f32 = 0.7;
const NMS_THRESH: f32 = 0.4;
/// FPN strides and anchors per cell for the det_10g model.
const STRIDES: [u32; 3] = [8, 16, 32];
@@ -23,12 +23,17 @@ pub struct Detection {
pub struct Scrfd {
session: Mutex<Session>,
score_thresh: f32,
}
impl Scrfd {
pub fn load(model_path: &Path) -> Result<Self, InferError> {
Self::load_with_threshold(model_path, DEFAULT_SCORE_THRESH)
}
pub fn load_with_threshold(model_path: &Path, score_thresh: f32) -> Result<Self, InferError> {
let session = Session::builder()?.commit_from_file(model_path)?;
Ok(Self { session: Mutex::new(session) })
Ok(Self { session: Mutex::new(session), score_thresh })
}
pub fn detect(&self, image_bytes: &[u8]) -> Result<Vec<Detection>, InferError> {
@@ -65,7 +70,7 @@ impl Scrfd {
for a in 0..num_anchors {
// scores_raw shape: [1, num_anchors, 1] or [num_anchors] — iterate flat
let score = scores_raw[a];
if score < SCORE_THRESH {
if score < self.score_thresh {
continue;
}

View File

@@ -93,7 +93,8 @@ pub async fn ingest_galleries(
tasks.push(tokio::spawn(async move {
let _permit = sem.acquire_owned().await.unwrap();
process_image(&pool, ml.as_ref(), &gid, &image_path, ordering as i32).await
let result = process_image(&pool, ml.as_ref(), &gid, &image_path, ordering as i32).await;
(image_path, result)
}));
}
@@ -104,7 +105,7 @@ pub async fn ingest_galleries(
for task in tasks {
match task.await {
Ok(Ok((skipped, faces))) => {
Ok((_, Ok((skipped, faces)))) => {
if skipped {
report.images_skipped += 1;
g_skipped += 1;
@@ -115,9 +116,9 @@ pub async fn ingest_galleries(
g_faces += faces;
}
}
Ok(Err(e)) => {
warn!("Image error: {e:#}");
report.errors.push((gallery_path.clone(), e));
Ok((image_path, Err(e))) => {
warn!("{} {} {e:#}", gid.to_hex(), image_path.display());
report.errors.push((image_path, e));
g_errors += 1;
}
Err(e) => {
@@ -128,9 +129,9 @@ pub async fn ingest_galleries(
}
let label = if !gallery.source_name.is_empty() {
gallery.source_name.clone()
format!("{} {}", gid.to_hex(), gallery.source_name)
} else {
gallery_path.display().to_string()
format!("{} {}", gid.to_hex(), gallery_path.display())
};
info!(
images = g_processed,

View File

@@ -4,15 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["http"]
http = ["dep:reqwest"]
[dependencies]
rbv-entity = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
reqwest = { workspace = true, optional = true }

View File

@@ -1,28 +1,7 @@
use thiserror::Error;
use tracing::debug;
use crate::MlBackend;
use crate::response::{AnalysisResult, ParseError};
#[cfg(feature = "http")]
use crate::request::{image_entries, text_entries};
#[cfg(feature = "http")]
use crate::response::{RawPredictResponse, RawTextResponse};
const DEFAULT_CLIP_MODEL: &str = "ViT-B-32__openai";
const DEFAULT_FACE_MODEL: &str = "buffalo_l";
#[derive(Debug, Error)]
pub enum MlError {
#[cfg(feature = "http")]
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[cfg(feature = "http")]
#[error("HTTP {status}: {body}")]
HttpStatus { status: u16, body: String },
#[error("Parse error: {0}")]
Parse(#[from] ParseError),
#[error("Missing embedding in response")]
MissingEmbedding,
#[error("Inference error: {0}")]
Inference(String),
}
@@ -30,119 +9,6 @@ pub enum MlError {
impl MlError {
/// Returns true if the error is transient and worth retrying.
pub fn is_retryable(&self) -> bool {
match self {
#[cfg(feature = "http")]
MlError::Http(e) => e.is_connect() || e.is_timeout() || e.is_request(),
#[cfg(feature = "http")]
MlError::HttpStatus { status, .. } => *status >= 500,
_ => false,
}
}
}
#[cfg(feature = "http")]
#[derive(Clone)]
pub struct MlClient {
http: reqwest::Client,
predict_url: String,
clip_model: String,
face_model: String,
}
#[cfg(feature = "http")]
impl MlClient {
pub fn new(base_url: &str) -> Self {
let base = base_url.trim_end_matches('/');
Self {
http: reqwest::Client::new(),
predict_url: format!("{base}/predict"),
clip_model: DEFAULT_CLIP_MODEL.to_string(),
face_model: DEFAULT_FACE_MODEL.to_string(),
}
}
pub fn with_models(mut self, clip_model: &str, face_model: &str) -> Self {
self.clip_model = clip_model.to_string();
self.face_model = face_model.to_string();
self
}
}
#[cfg(feature = "http")]
#[async_trait::async_trait]
impl MlBackend for MlClient {
/// Submit an image for CLIP visual embedding + face detection/recognition.
async fn analyze_image(&self, image_bytes: &[u8]) -> Result<AnalysisResult, MlError> {
let entries = image_entries(&self.clip_model, &self.face_model);
let image_part = reqwest::multipart::Part::bytes(image_bytes.to_vec())
.file_name("image.jpg")
.mime_str("image/jpeg")
.unwrap();
let form = reqwest::multipart::Form::new()
.part("image", image_part)
.text("entries", entries);
let is_jpeg = image_bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
debug!(
url = %self.predict_url,
bytes = image_bytes.len(),
is_jpeg,
"Submitting image to ML API",
);
if !is_jpeg {
tracing::warn!(
"Image does not have JPEG magic bytes; first 4: {:02X?}",
&image_bytes[..4.min(image_bytes.len())]
);
}
let resp = self.http
.post(&self.predict_url)
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(MlError::HttpStatus { status, body });
}
let response = resp.json::<RawPredictResponse>().await?;
Ok(response.into_analysis_result()?)
}
/// Submit a text query for CLIP text embedding.
async fn embed_text(&self, text: &str) -> Result<Vec<f32>, MlError> {
let entries = text_entries(&self.clip_model);
let form = reqwest::multipart::Form::new()
.text("text", text.to_string())
.text("entries", entries);
debug!(url = %self.predict_url, text, "Submitting text to ML API");
let resp = self.http
.post(&self.predict_url)
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(MlError::HttpStatus { status, body });
}
let response = resp.json::<RawTextResponse>().await?;
let clip_str = response.clip.ok_or(MlError::MissingEmbedding)?;
let embedding: Vec<f32> = serde_json::from_str(&clip_str)
.map_err(|e| MlError::Parse(ParseError::InvalidEmbedding(e)))?;
Ok(embedding)
false
}
}

View File

@@ -1,8 +1,7 @@
pub mod client;
pub mod request;
pub mod response;
pub use client::{MlClient, MlError};
pub use client::MlError;
pub use response::{AnalysisResult, DetectedFace};
#[async_trait::async_trait]

View File

@@ -1,37 +0,0 @@
use serde_json::{json, Value};
/// Build the `entries` JSON string for a combined CLIP visual + facial recognition request.
pub(crate) fn image_entries(clip_model: &str, face_model: &str) -> String {
let entries: Value = json!({
"clip": {
"visual": {
"modelName": clip_model,
"options": {}
}
},
"facial-recognition": {
"detection": {
"modelName": face_model,
"options": {}
},
"recognition": {
"modelName": face_model,
"options": {}
}
}
});
entries.to_string()
}
/// Build the `entries` JSON string for a text CLIP embedding request.
pub(crate) fn text_entries(clip_model: &str) -> String {
let entries: Value = json!({
"clip": {
"textual": {
"modelName": clip_model,
"options": {}
}
}
});
entries.to_string()
}

View File

@@ -1,16 +1,6 @@
use serde::Deserialize;
use rbv_entity::BoundingBox;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ParseError {
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("invalid embedding: {0}")]
InvalidEmbedding(#[from] serde_json::Error),
}
/// Public result type returned to callers of MlClient.
/// Result type returned by MlBackend implementations.
#[derive(Debug, Clone)]
pub struct AnalysisResult {
pub clip_embedding: Vec<f32>,
@@ -25,71 +15,3 @@ pub struct DetectedFace {
pub embedding: Vec<f32>,
pub detection_score: f32,
}
// ---------- wire format (private) ----------
#[derive(Deserialize)]
pub(crate) struct RawPredictResponse {
/// CLIP embedding encoded as a JSON array string, e.g. "[0.1, -0.2, ...]"
pub clip: Option<String>,
#[serde(rename = "facial-recognition")]
pub facial_recognition: Option<Vec<RawFaceDetection>>,
#[serde(rename = "imageHeight")]
pub image_height: Option<i32>,
#[serde(rename = "imageWidth")]
pub image_width: Option<i32>,
}
#[derive(Deserialize)]
pub(crate) struct RawFaceDetection {
#[serde(rename = "boundingBox")]
pub bounding_box: RawBoundingBox,
/// Face embedding encoded as a JSON array string
pub embedding: String,
pub score: f32,
}
#[derive(Deserialize)]
pub(crate) struct RawBoundingBox {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
}
impl RawPredictResponse {
pub(crate) fn into_analysis_result(self) -> Result<AnalysisResult, ParseError> {
let clip_str = self.clip.ok_or(ParseError::MissingField("clip"))?;
let clip_embedding: Vec<f32> = serde_json::from_str(&clip_str)?;
let faces = self.facial_recognition.unwrap_or_default()
.into_iter()
.map(|f| {
let embedding: Vec<f32> = serde_json::from_str(&f.embedding)?;
Ok(DetectedFace {
bounding_box: BoundingBox {
x1: f.bounding_box.x1 as i32,
y1: f.bounding_box.y1 as i32,
x2: f.bounding_box.x2 as i32,
y2: f.bounding_box.y2 as i32,
},
embedding,
detection_score: f.score,
})
})
.collect::<Result<Vec<_>, serde_json::Error>>()?;
Ok(AnalysisResult {
clip_embedding,
faces,
image_width: self.image_width.unwrap_or(0),
image_height: self.image_height.unwrap_or(0),
})
}
}
/// Wire response for a text-only CLIP embedding request.
#[derive(Deserialize)]
pub(crate) struct RawTextResponse {
pub clip: Option<String>,
}

294
doc/db-cluster.md Normal file
View File

@@ -0,0 +1,294 @@
# PostgreSQL Cluster
Three-node Patroni cluster with pgvector, consolidating the rbv and immich
PostgreSQL containers previously running on gramathea.
All connections (client and replication) use mutual TLS via the internal
step CA. No password authentication is used anywhere.
Certificate convention on all infra hosts:
- CA: `/etc/pki/ca-trust/source/anchors/root-internal.pem`
- Cert: `/etc/pki/tls/misc/$(hostname -f).pem`
- Key: `/etc/pki/tls/private/$(hostname -f).pem`
Certs are provisioned as both client and server, so the same PEMs serve
for PostgreSQL SSL, client certificate authentication, and Patroni
replication.
## Nodes
| Hostname | Site | Role |
|---|---|---|
| frankie.hanzalova.internal | primary | primary site, node 1 |
| _(TBD)_ | primary | primary site, node 2 |
| _(TBD)_ | secondary | secondary site, node 3 |
Hardware: ASRock E3C236D4M-4L, E3-1230 v6, 16 GB RAM, 2×1 TB SSD.
Replication topology (target):
- Primary → sync standby: within primary site (no WireGuard on critical write path)
- Primary → async standby: secondary site node (DR copy)
## Phase 1 — Standalone on frankie
### Install PostgreSQL 17 and pgvector
```bash
# Add PGDG repository
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/F-$(rpm -E %fedora)-x86_64/pgdg-fedora-repo-latest.noarch.rpm
# Disable the Fedora-packaged postgres to avoid conflicts
sudo dnf -qy module disable postgresql
# Install server and pgvector
sudo dnf install -y postgresql17-server pgvector_17
# Initialise the data directory
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
# Enable and start
sudo systemctl enable --now postgresql-17.service
```
### Make certificates readable by postgres
Grant the postgres user read access via ACL, leaving ownership as root.
This way cert renewals take effect automatically without re-copying.
```bash
sudo setfacl -m u:postgres:r /etc/pki/tls/private/$(hostname).pem
```
### Configure postgresql.conf
```bash
sudo -u postgres mkdir -p /var/lib/pgsql/17/data/postgresql.conf.d
if ! sudo -u postgres grep 'postgresql.conf.d' /var/lib/pgsql/17/data/postgresql.conf &> /dev/null; then
echo 'include_dir = postgresql.conf.d' | sudo -u postgres tee --append /var/lib/pgsql/17/data/postgresql.conf
fi
echo "listen_addresses = '*'" | sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/listen.conf
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/ssl.conf <<'EOF'
ssl = on
ssl_cert_file = '/etc/pki/tls/misc/frankie.hanzalova.internal.pem'
ssl_key_file = '/etc/pki/tls/private/frankie.hanzalova.internal.pem'
ssl_ca_file = '/etc/pki/ca-trust/source/anchors/root-internal.pem'
EOF
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/memory.conf <<'EOF'
shared_buffers = 4GB
work_mem = 64MB
maintenance_work_mem = 512MB
EOF
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/wal.conf <<'EOF'
wal_level = replica
max_wal_senders = 5
wal_keep_size = 1GB
EOF
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/checkpoint.conf <<'EOF'
checkpoint_completion_target = 0.9
EOF
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/vchord.conf <<'EOF'
shared_preload_libraries = 'vchord'
EOF
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/logging.conf <<'EOF'
log_destination = 'stderr'
logging_collector = off
EOF
sudo systemctl reload postgresql-17.service
```
### Configure pg_hba.conf
Update the default rules with certificate-only authentication for lan connections.
Local unix-socket access retains `peer` for admin use.
```bash
sudo -u postgres mkdir -p /var/lib/pgsql/17/data/pg_hba.conf.d
if ! sudo -u postgres grep 'pg_hba.conf.d' /var/lib/pgsql/17/data/pg_hba.conf &> /dev/null; then
echo 'include_dir = pg_hba.conf.d' | sudo -u postgres tee --append /var/lib/pgsql/17/data/pg_hba.conf
fi
sudo -u postgres tee /var/lib/pgsql/17/data/pg_hba.conf.d/network-connections.conf <<'EOF'
hostnossl all all 0.0.0.0/0 reject
hostssl all all 10.3.0.0/16 cert map=cert_cn
hostssl all all 10.6.0.0/16 cert map=cert_cn
hostssl replication replicator 10.0.0.0/8 cert clientcert=verify-full map=cn
EOF
sudo systemctl reload postgresql-17.service
```
### Configure pg_ident.conf
Maps the CN of each client certificate to the appropriate database user.
Add a line for each application host.
```bash
sudo -u postgres mkdir -p /var/lib/pgsql/17/data/pg_ident.conf.d
if ! sudo -u postgres grep 'pg_ident.conf.d' /var/lib/pgsql/17/data/pg_ident.conf &> /dev/null; then
echo 'include_dir = pg_ident.conf.d' | sudo -u postgres tee --append /var/lib/pgsql/17/data/pg_ident.conf
fi
sudo -u postgres tee /var/lib/pgsql/17/data/pg_ident.conf.d/immich.conf <<'EOF'
cn gramathea.kosherinata.internal immich
EOF
sudo -u postgres tee /var/lib/pgsql/17/data/pg_ident.conf.d/rbv.conf <<'EOF'
cn gramathea.kosherinata.internal rbv
EOF
sudo systemctl reload postgresql-17.service
```
### Create roles and databases
No passwords — authentication is via certificate only.
```bash
sudo -u postgres psql <<'EOF'
CREATE USER rbv;
CREATE DATABASE rbv OWNER rbv;
CREATE USER immich;
CREATE DATABASE immich OWNER immich;
CREATE USER replicator REPLICATION;
EOF
```
### Install VectorChord
VectorChord is not in PGDG — install from the GitHub release zip.
Check https://github.com/tensorchord/VectorChord/releases for the current version.
```bash
curl \
--fail \
--show-error \
--location \
--silent \
--output /tmp/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip \
--url https://github.com/tensorchord/VectorChord/releases/download/1.1.1/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip
unzip \
-d /tmp/vchord \
/tmp/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip
sudo install \
--owner root \
--group root \
/tmp/vchord/pkglibdir/vchord.so \
/usr/pgsql-17/lib/
sudo install \
--owner root \
--group root \
--mode 644 \
/tmp/vchord/sharedir/extension/vchord* \
/usr/pgsql-17/share/extension/
rm -rf /tmp/vchord /tmp/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip
```
VectorChord requires preloading (needs a restart, not just reload):
> [!CAUTION]
> deprecated in favour of `/var/lib/pgsql/17/data/postgresql.conf.d/vchord.conf` above.
```bash
sudo tee -a /var/lib/pgsql/17/data/postgresql.conf <<'EOF'
# VectorChord
shared_preload_libraries = 'vchord'
EOF
sudo systemctl restart postgresql-17
```
### Enable pgvector and VectorChord
```bash
sudo -u postgres psql -d rbv <<'EOF'
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS vchord;
EOF
sudo -u postgres psql -d immich <<'EOF'
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS vchord;
EOF
```
### Open firewall port
```bash
sudo firewall-cmd --zone=$(firewall-cmd --get-default-zone) --add-service postgresql --permanent
sudo firewall-cmd --reload
sudo firewall-cmd --list-services
```
### Migrate data from gramathea
Run on gramathea. The dump uses password auth against the existing
containers; the restore connects to frankie using the host certificate.
```bash
# Dump from the running quadlet containers (password auth, local)
pg_dump -h localhost -p 4432 -U rbv rbv > rbv.sql
pg_dump -h localhost -p 5432 -U postgres immich > immich.sql # adjust port/user for immich container
# Restore on frankie using cert auth
psql "host=frankie.hanzalova.internal \
user=rbv dbname=rbv \
sslmode=verify-full \
sslcert=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem \
sslkey=/etc/pki/tls/private/gramathea.kosherinata.internal.pem \
sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem" \
< rbv.sql
psql "host=frankie.hanzalova.internal \
user=immich dbname=immich \
sslmode=verify-full \
sslcert=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem \
sslkey=/etc/pki/tls/private/gramathea.kosherinata.internal.pem \
sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem" \
< immich.sql
```
### Update application connection strings
rbv services use the connection string format accepted by libpq/sqlx.
SSL parameters can be passed inline or via environment variables; inline
is shown here for clarity. Update `/etc/systemd/system/rbv-*.service`:
```
postgres://rbv@frankie.hanzalova.internal/rbv\
?sslmode=verify-full\
&sslcert=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem\
&sslkey=/etc/pki/tls/private/gramathea.kosherinata.internal.pem\
&sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem
```
The `sed -i` password substitution in `script/deploy.sh` can be removed
once the services are updated to cert-based connection strings.
For immich, update `DB_HOSTNAME`, `DB_USERNAME`, and set:
```
DB_SSL_MODE=verify-full
DB_SSL_CERT=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem
DB_SSL_KEY=/etc/pki/tls/private/gramathea.kosherinata.internal.pem
DB_SSL_ROOT_CERT=/etc/pki/ca-trust/source/anchors/root-internal.pem
```
Once both applications are confirmed working against frankie, stop and
disable the postgres quadlets on gramathea:
```bash
sudo systemctl disable --now rbv-postgres.service
# and the immich postgres equivalent
```
## Phase 2 — Patroni HA (when second node is ready)
_To be documented once node 2 hardware is provisioned._
Key steps will be:
1. Install etcd on all three nodes
2. Install Patroni on all three nodes
3. Bootstrap Patroni on node 1 (adopts existing data directory — no re-initdb)
4. Stream base backup to node 2, add as sync standby (cert auth for replication)
5. Add node 3 as async standby

View File

@@ -0,0 +1,42 @@
-- Denormalized image_count on persons for fast sort-by-popularity.
-- Maintained by a trigger on face_detections.
ALTER TABLE persons ADD COLUMN IF NOT EXISTS image_count BIGINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_persons_image_count ON persons (image_count DESC, created_at);
-- Trigger function: recompute image_count for affected person(s).
CREATE OR REPLACE FUNCTION fn_update_person_image_count() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
IF NEW.person_id IS NOT NULL THEN
UPDATE persons
SET image_count = (
SELECT COUNT(DISTINCT image_id)
FROM face_detections
WHERE person_id = NEW.person_id
)
WHERE id = NEW.person_id;
END IF;
END IF;
IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
IF OLD.person_id IS NOT NULL AND (TG_OP = 'DELETE' OR OLD.person_id IS DISTINCT FROM NEW.person_id) THEN
UPDATE persons
SET image_count = (
SELECT COUNT(DISTINCT image_id)
FROM face_detections
WHERE person_id = OLD.person_id
)
WHERE id = OLD.person_id;
END IF;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_fd_person_image_count
AFTER INSERT OR UPDATE OF person_id OR DELETE
ON face_detections
FOR EACH ROW EXECUTE FUNCTION fn_update_person_image_count();

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env bash
postgres_host=gramathea.kosherinata.internal
api_host=gramathea.kosherinata.internal
@@ -111,6 +112,7 @@ deploy_ui() {
${ui_host}:/tmp/provisioner \
&& ssh ${ui_host} sudo mkdir -p /etc/nginx/tls/rbv \
&& ssh ${ui_host} sudo step ca certificate \
--force \
--provisioner lair \
--provisioner-password-file /tmp/provisioner \
--ca-url https://ca.internal \
@@ -126,18 +128,37 @@ deploy_ui() {
exit 1
fi
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
asset/systemd/step@.service \
${ui_host}:/etc/systemd/system/step@.service; then
echo 'step cert renewal service deployed successfully'
else
echo 'failed to deploy step cert renewal service'
exit 1
fi
for unit in step@.{service,timer}; do
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
asset/systemd/${unit} \
${ui_host}:/etc/systemd/system/${unit}; then
echo "${unit} deployed successfully"
else
echo "failed to deploy ${unit}"
exit 1
fi
done
ssh ${ui_host} "
sudo systemctl daemon-reload
if ! systemctl is-enabled --quiet step@rbv.timer; then
if sudo systemctl enable step@rbv.timer; then
echo 'step@rbv.timer enabled'
else
echo 'failed to enable step@rbv.timer'
fi
fi
if ! systemctl is-active --quiet step@rbv.timer; then
if sudo systemctl start step@rbv.timer; then
echo 'step@rbv.timer started'
else
echo 'failed to start step@rbv.timer'
fi
fi
"
(cd ui && npm run build)
if ssh ${ui_host} sudo mkdir -p /usr/share/nginx/rbv \

View File

@@ -64,6 +64,8 @@ export const getGallery = (id: string) => request<Gallery>(`/galleries/${id}`)
export const getGalleryImages = (id: string) => request<GalleryImage[]>(`/galleries/${id}/images`)
export const getGalleryPersons = (id: string) => request<Person[]>(`/galleries/${id}/persons`)
// ── Images ────────────────────────────────────────────────────────────────────
export interface ImageMeta {
@@ -85,6 +87,7 @@ export interface Person {
primary_name: string | null
names: string[]
created_at: string
image_count: number
}
export interface FaceRef {

View File

@@ -20,9 +20,24 @@
flex: 1;
}
.gallery-faces {
.gallery-persons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.gallery-person-link {
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--colour-surface, #f0f0f0);
color: var(--colour-link, #0969da);
text-decoration: none;
font-size: 0.85rem;
}
.gallery-person-link:hover {
background: var(--colour-link, #0969da);
color: #fff;
}
.gallery-viewer {

View File

@@ -1,11 +1,11 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { useParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
getGallery, getGalleryImages, faceCropUrl,
getGallery, getGalleryImages, getGalleryPersons,
imageFileUrl, thumbnailUrl,
} from '../api/client'
import type { Gallery as GalleryType, GalleryImage, FaceRef } from '../api/client'
import type { Gallery as GalleryType, GalleryImage, Person } from '../api/client'
import './Gallery.css'
export function Gallery() {
@@ -14,7 +14,7 @@ export function Gallery() {
const [gallery, setGallery] = useState<GalleryType | null>(null)
const [images, setImages] = useState<GalleryImage[]>([])
const [current, setCurrent] = useState(0)
const [faces, setFaces] = useState<FaceRef[]>([])
const [persons, setPersons] = useState<Person[]>([])
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [dragging, setDragging] = useState(false)
@@ -40,10 +40,9 @@ export function Gallery() {
strip.scrollTo({ left: target, behavior: 'smooth' })
}, [current])
// Load faces appearing in this gallery's images (first 10 distinct persons)
useEffect(() => {
// Placeholder — face-per-gallery query not in API; skip for now
setFaces([])
if (!id) return
getGalleryPersons(id).then(setPersons).catch(() => setPersons([]))
}, [id])
const prev = useCallback(() => setCurrent(c => (c - 1 + images.length) % images.length), [images.length])
@@ -115,11 +114,15 @@ export function Gallery() {
<div className="gallery-page">
<div className="gallery-header">
<div className="gallery-title">{gallery.source_name || gallery.collection}</div>
<div className="gallery-faces">
{faces.map(f => (
<img key={f.id} className="face-avatar" src={faceCropUrl(f.id)} alt="" loading="lazy" />
))}
</div>
{persons.length > 0 && (
<div className="gallery-persons">
{persons.map(p => (
<Link key={p.id} to={`/people/${p.id}`} className="gallery-person-link">
{p.primary_name ?? 'Unnamed'}
</Link>
))}
</div>
)}
</div>
<div