From 01f52565f460a0107679999588b73b770f01a98c Mon Sep 17 00:00:00 2001 From: Ken D'Ambrosio Date: Mon, 8 Jun 2026 19:00:02 +0000 Subject: Add people/face clustering feature - scripts/cluster_faces.py: greedy centroid clustering (numpy) with 3 refinement passes; preserves existing UUID/name mappings across re-runs; writes MEDIA_ROOT/people.json atomically. - app.rb: GET /face/* serves cropped+padded face thumbnails (100x100, cached under cache/faces/); GET|POST /admin/people for cluster management; POST /admin/people/recluster runs cluster_faces.py as a background job; POST /admin/people/:uuid saves names+slugs; GET /people public grid of named people; GET /people/:slug photos for one person. - views/admin/people.erb: lists all clusters (named first, then by size), face crop samples, inline name form, re-cluster button with live log. - views/people.erb: public grid of named people. - views/person.erb: photo grid for one person, linking back to album lightbox for each photo. - views/layout.erb: People link in nav (conditional on FACES_ENABLED). - public/css/style.css: styles for people admin list and public tiles. Co-Authored-By: Claude Sonnet 4.6 --- app.rb | 177 +++++++++++++++++++++++++++++++++++++ public/css/style.css | 43 +++++++++ scripts/cluster_faces.py | 223 +++++++++++++++++++++++++++++++++++++++++++++++ views/admin/people.erb | 111 +++++++++++++++++++++++ views/layout.erb | 2 + views/people.erb | 25 ++++++ views/person.erb | 26 ++++++ 7 files changed, 607 insertions(+) create mode 100644 scripts/cluster_faces.py create mode 100644 views/admin/people.erb create mode 100644 views/people.erb create mode 100644 views/person.erb diff --git a/app.rb b/app.rb index a923b4d..5172eeb 100644 --- a/app.rb +++ b/app.rb @@ -25,6 +25,10 @@ MEDIA_EXTS = (IMAGE_EXTS + VIDEO_EXTS + AUDIO_EXTS).freeze APP_CONFIG = (File.exist?(CONFIG_PATH) ? YAML.load_file(CONFIG_PATH, symbolize_names: true) : {}).freeze FACES_ENABLED = (APP_CONFIG.dig(:faces, :enabled) == true).freeze +VENV_PYTHON = (ENV['VENV_PYTHON'] || '/opt/albumen/venv/bin/python3').freeze +CLUSTER_SCRIPT = File.join(__dir__, 'scripts', 'cluster_faces.py').freeze +PEOPLE_PATH = File.join(MEDIA_ROOT, 'people.json').freeze +FACES_CACHE = (ENV['FACES_CACHE'] || '/opt/albumen/cache/faces').freeze # ── Sinatra config ───────────────────────────────────────────────────────────── @@ -231,6 +235,39 @@ helpers do type = @og_use_media ? 'media' : 'thumb' "#{request.base_url}/#{type}/#{@og_image_rel}" end + + def load_people_data + return {} unless File.exist?(PEOPLE_PATH) + JSON.parse(File.read(PEOPLE_PATH)) + rescue JSON::ParserError + {} + end + + def face_crop_cache(rel, box) + File.join(FACES_CACHE, File.dirname(rel), "#{File.basename(rel)}.#{box.join('-')}.jpg") + end + + def generate_face_crop(full_path, box, cache_path) + top, right, bottom, left = box.map(&:to_i) + img = MiniMagick::Image.open(full_path) + iw, ih = img.width, img.height + pad_y = ((bottom - top) * 0.5).to_i + pad_x = ((right - left) * 0.5).to_i + y1 = [top - pad_y, 0].max + x1 = [left - pad_x, 0].max + y2 = [bottom + pad_y, ih].min + x2 = [right + pad_x, iw].min + cw = [x2 - x1, 1].max + ch = [y2 - y1, 1].max + img.combine_options do |c| + c.auto_orient + c.crop "#{cw}x#{ch}+#{x1}+#{y1}" + c.resize '100x100!' + end + img.write(cache_path) + rescue StandardError => e + warn "face crop error #{full_path}: #{e.message}" + end end # ── Public routes ────────────────────────────────────────────────────────────── @@ -597,6 +634,146 @@ post '/admin/upload' do { job_id: job_id, saved: saved, album_rel: target_rel }.to_json end +# ── Face crops ──────────────────────────────────────────────────────────────── + +get '/face/*' do + rel = params[:splat].first + full = resolve_file(rel) + halt 404 unless File.file?(full) + + dir = File.dirname(full) + name = File.basename(full) + meta = (load_album(dir)['files'] || {})[name] || {} + halt 403 if meta['visible'] == false && !admin? + + box = params[:box].to_s.split(',').map(&:to_i) + halt 400 unless box.length == 4 + + cache = face_crop_cache(rel, box) + unless File.exist?(cache) + FileUtils.mkdir_p(File.dirname(cache)) + generate_face_crop(full, box, cache) + end + halt 404 unless File.exist?(cache) + send_file cache, type: 'image/jpeg' +end + +# ── People ───────────────────────────────────────────────────────────────────── + +get '/admin/people' do + require_admin! + data = load_people_data + people = data['people'] || {} + + @title = 'People' + @total = people.length + @named_count = people.count { |_, p| p['name'] } + @updated_at = data['updated_at'] + + named = people.select { |_, p| p['name'] }.sort_by { |_, p| p['name'].downcase } + unnamed = people.reject { |_, p| p['name'] }.sort_by { |_, p| -(p['members']&.length || 0) } + + @clusters = (named + unnamed).first(200).map do |uid, p| + members = p['members'] || [] + { uuid: uid, name: p['name'], slug: p['slug'], + count: members.length, + samples: members.first(6).map { |m| { rel: m['rel'], box: m['box'] } } } + end + + erb :'admin/people' +end + +post '/admin/people/recluster' do + require_admin! + job_id = SecureRandom.hex(8) + cmd = [VENV_PYTHON, CLUSTER_SCRIPT] + + UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id] = { status: :running, lines: [] } } + Thread.new do + begin + IO.popen(cmd, err: [:child, :out]) do |io| + io.each_line { |l| UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:lines] << l.chomp } } + end + code = $?.exitstatus + UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:status] = code.zero? ? :done : :error } + rescue => e + UPDATE_JOBS_MUTEX.synchronize do + UPDATE_JOBS[job_id][:status] = :error + UPDATE_JOBS[job_id][:lines] << "Error: #{e.message}" + end + end + end + + content_type :json + { job_id: job_id }.to_json +end + +post '/admin/people/:uuid' do + require_admin! + uid = params[:uuid] + name = params['name'].to_s.strip + + data = load_people_data + people = data['people'] || {} + halt 404 unless people.key?(uid) + + slug = nil + unless name.empty? + base = name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-+|-+$/, '') + used = people.reject { |k, _| k == uid }.values.map { |p| p['slug'] }.compact + if used.include?(base) + i = 2 + i += 1 while used.include?("#{base}-#{i}") + slug = "#{base}-#{i}" + else + slug = base + end + end + + people[uid]['name'] = name.empty? ? nil : name + people[uid]['slug'] = slug + data['people'] = people + + atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) + redirect '/admin/people' +end + +get '/people' do + data = load_people_data + people = (data['people'] || {}).select { |_, p| p['name'] && p['slug'] } + + @title = 'People' + @people = people.filter_map do |_, p| + face = p['members']&.first + next unless face + { slug: p['slug'], name: p['name'], + count: (p['members'] || []).length, + face: { rel: face['rel'], box: face['box'] } } + end.sort_by { |p| p[:name].downcase } + + erb :people +end + +get '/people/:slug' do + data = load_people_data + person = (data['people'] || {}).values.find { |p| p['slug'] == params[:slug] } + halt 404 unless person + + seen = {} + photos = (person['members'] || []).filter_map do |m| + next if seen[m['rel']] + seen[m['rel']] = true + parts = m['rel'].split('/') + { rel: m['rel'], filename: parts.last, dir_rel: parts[0..-2].join('/') } + end + + @title = person['name'] + @person_name = person['name'] + @photos = photos + @count = photos.length + erb :person +end + # ── Thumbnail generation ─────────────────────────────────────────────────────── def generate_thumb(source, dest, ext) diff --git a/public/css/style.css b/public/css/style.css index ec22f39..ba9b822 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -421,6 +421,49 @@ tr.delete-marked td { background: rgba(192,57,43,.08); } padding: 12px 14px; border-radius: var(--radius); max-height: 340px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin: 0; } +/* ── People — admin ───────────────────────────────────────────────────── */ +.admin-people h1 { font-size: 1.4rem; margin: 16px 0; } + +.people-admin-list { margin-top: 24px; display: flex; flex-direction: column; gap: 4px; } + +.people-admin-row { + display: flex; align-items: center; gap: 14px; + padding: 10px 12px; + background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); +} +.people-admin-row:hover { background: var(--bg3); } +.unnamed-cluster { opacity: .7; } +.unnamed-cluster:hover { opacity: 1; } + +.face-samples { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } +.face-samples img { border-radius: 50%; object-fit: cover; border: 2px solid var(--bg3); } +.face-more { font-size: .8rem; color: var(--text-dim); white-space: nowrap; min-width: 28px; } + +.people-admin-meta { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; } +.face-count { font-size: .8rem; color: var(--text-dim); white-space: nowrap; min-width: 64px; } + +.name-form { display: flex; align-items: center; gap: 6px; flex: 1; } +.name-input { + flex: 1; max-width: 280px; + background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); padding: 5px 10px; font-size: .9rem; +} +.name-input:focus { border-color: var(--accent); outline: none; } + +/* ── People — public ───────────────────────────────────────────────────── */ +.people-grid { margin-top: 8px; } + +.person-tile { + display: block; border-radius: var(--radius); overflow: hidden; + background: var(--bg2); text-decoration: none; + transition: transform .15s, box-shadow .15s; +} +.person-tile:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.5); text-decoration: none; } +.person-tile .thumb-wrap img { transition: transform .2s; } +.person-tile:hover .thumb-wrap img { transform: scale(1.04); } +.person-name { padding: 7px 10px 2px; font-size: .9rem; font-weight: 500; color: var(--text); } +.person-count { padding: 0 10px 8px; font-size: .75rem; color: var(--text-dim); } + /* ── Admin upload ──────────────────────────────────────────────────────── */ .admin-upload { margin-top: 32px; } .admin-upload h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 6px; } diff --git a/scripts/cluster_faces.py b/scripts/cluster_faces.py new file mode 100644 index 0000000..92c64fe --- /dev/null +++ b/scripts/cluster_faces.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Cluster face encodings from per-directory faces.json sidecars into people. + +Reads all faces.json files under MEDIA_ROOT, groups face encodings by +similarity (L2 distance < threshold), and writes MEDIA_ROOT/people.json. + +Existing names and UUIDs in people.json are preserved across re-runs: any +cluster whose members overlap with a named cluster in the old file keeps that +UUID and name. + +Usage: + python3 cluster_faces.py [--threshold 0.55] [--media-root /var/albumen] + +Threshold guide: + 0.50 — very strict; may split the same person across lighting/angle + 0.55 — good default for family photos + 0.60 — lenient; may merge different people who look alike +""" +import argparse +import glob +import json +import os +import sys +import uuid as _uuid +from datetime import datetime, timezone + +try: + import numpy as np +except ImportError: + print("numpy is required: /opt/albumen/venv/bin/pip install numpy", file=sys.stderr) + sys.exit(1) + +MEDIA_ROOT = os.environ.get('MEDIA_ROOT', '/var/albumen') + + +def collect_faces(media_root): + """Return list of {rel, box, encoding} for all processed face instances.""" + faces = [] + for path in sorted(glob.glob(os.path.join(media_root, '**', 'faces.json'), recursive=True)): + dir_abs = os.path.dirname(path) + dir_rel = dir_abs[len(media_root):].lstrip('/') + try: + data = json.load(open(path)) + except Exception: + continue + for filename, face_list in data.items(): + if not isinstance(face_list, list): + continue + for face in face_list: + enc = face.get('encoding') + box = face.get('box') + if not enc or not box or len(enc) != 128: + continue + rel = f"{dir_rel}/{filename}" if dir_rel else filename + faces.append({'rel': rel, 'box': box, 'encoding': enc}) + return faces + + +def cluster(encodings, threshold): + """ + Greedy centroid clustering with up to 3 refinement passes. + Returns an integer label array of length n. + """ + n = len(encodings) + if n == 0: + return np.array([], dtype=np.int32) + + # First pass: greedy — each face goes to the nearest centroid or starts a new cluster + labels = np.zeros(n, dtype=np.int32) + cent_sum = [encodings[0].copy()] + cent_count = [1] + + for i in range(1, n): + means = np.vstack(cent_sum) / np.array(cent_count, dtype=np.float32)[:, np.newaxis] + dists = np.sqrt(np.sum((encodings[i] - means) ** 2, axis=1)) + best = int(np.argmin(dists)) + if dists[best] < threshold: + labels[i] = best + cent_sum[best] += encodings[i] + cent_count[best] += 1 + else: + labels[i] = len(cent_sum) + cent_sum.append(encodings[i].copy()) + cent_count.append(1) + + # Refinement passes: reassign each face to the nearest centroid + for _ in range(3): + k = int(labels.max()) + 1 + centroids = np.zeros((k, 128), dtype=np.float32) + counts = np.zeros(k, dtype=np.int32) + for i in range(n): + centroids[labels[i]] += encodings[i] + counts[labels[i]] += 1 + nz = counts > 0 + centroids[nz] /= counts[nz, np.newaxis] + + new_labels = np.zeros(n, dtype=np.int32) + chunk = 512 + for start in range(0, n, chunk): + end = min(start + chunk, n) + diff = encodings[start:end, np.newaxis, :] - centroids[np.newaxis, :, :] + dist = np.sqrt(np.sum(diff ** 2, axis=2)) # (c, k) + best = np.argmin(dist, axis=1) + bd = dist[np.arange(end - start), best] + for j in range(end - start): + new_labels[start + j] = int(best[j]) if bd[j] < threshold else int(labels[start + j]) + + if np.array_equal(new_labels, labels): + break + labels = new_labels + + # Compact: remap to 0..k-1 + unique = np.unique(labels) + remap = {int(old): new for new, old in enumerate(unique)} + return np.array([remap[int(l)] for l in labels], dtype=np.int32) + + +def load_people(path): + if not os.path.exists(path): + return {} + try: + return json.load(open(path)).get('people', {}) + except Exception: + return {} + + +def build_people(faces, labels, existing): + """ + Group faces into clusters and match against existing UUIDs/names. + A new cluster inherits the UUID and name of the existing cluster with the + most member overlap (≥1 shared photo). + """ + clusters = {} + for face, label in zip(faces, labels.tolist()): + clusters.setdefault(int(label), []).append(face) + + existing_rels = {uid: frozenset(m['rel'] for m in p.get('members', [])) + for uid, p in existing.items()} + used = set() + people = {} + + # Process largest clusters first so names attach to the most representative cluster + for label, members in sorted(clusters.items(), key=lambda x: -len(x[1])): + new_rels = frozenset(m['rel'] for m in members) + + best_uid, best_n = None, 0 + for uid, old_rels in existing_rels.items(): + if uid in used: + continue + n = len(new_rels & old_rels) + if n > best_n: + best_n, best_uid = n, uid + + if best_uid and best_n >= 1: + uid = best_uid + name = existing[uid].get('name') + slug = existing[uid].get('slug') + used.add(uid) + else: + uid = str(_uuid.uuid4()) + name = None + slug = None + + people[uid] = { + 'name': name, + 'slug': slug, + 'members': [{'rel': m['rel'], 'box': m['box']} for m in members], + } + + return people + + +def atomic_write(path, obj): + tmp = path + '.tmp.cluster' + with open(tmp, 'w') as f: + json.dump(obj, f) + os.rename(tmp, path) + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--threshold', type=float, default=0.55, + help='L2 distance threshold (default 0.55; lower = stricter)') + ap.add_argument('--media-root', default=MEDIA_ROOT) + args = ap.parse_args() + + people_path = os.path.join(args.media_root, 'people.json') + + print(f"Collecting faces from {args.media_root} ...") + faces = collect_faces(args.media_root) + print(f" {len(faces)} face instances") + + if not faces: + print("No faces to cluster. Run the face daemon first.") + return + + print(f"Clustering (threshold={args.threshold}) ...") + encodings = np.array([f['encoding'] for f in faces], dtype=np.float32) + labels = cluster(encodings, args.threshold) + k = int(labels.max()) + 1 + print(f" {k} clusters from {len(faces)} faces") + + existing = load_people(people_path) + print(f" {len(existing)} existing records (names preserved)") + + people = build_people(faces, labels, existing) + + result = { + 'updated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'threshold': args.threshold, + 'people': people, + } + + atomic_write(people_path, result) + named = sum(1 for p in people.values() if p.get('name')) + print(f"Wrote {people_path}: {named} named, {k - named} unnamed") + + +if __name__ == '__main__': + main() diff --git a/views/admin/people.erb b/views/admin/people.erb new file mode 100644 index 0000000..6b7a4d0 --- /dev/null +++ b/views/admin/people.erb @@ -0,0 +1,111 @@ +
+
+ ← Albums + <% unless @clusters.empty? %> + Public Page ↗ + <% end %> +
+ +

People

+ +
+

Clustering

+

+ <%= @total %> cluster<%= @total == 1 ? '' : 's' %> · + <%= @named_count %> named + <% if @updated_at %>· last run <%= @updated_at %><% end %> +

+

+ Re-clustering reads all faces.json files and groups similar faces together. + Existing names are preserved. Run again whenever the face daemon has processed a significant batch of new photos. +

+ + +
+ + <% if @clusters.empty? %> +

+ No face data yet — the face daemon is still processing, or run Re-cluster once it has finished a pass. +

+ <% else %> +
+ <% @clusters.each do |c| %> +
+
+ <% c[:samples].each do |s| %> + + <% end %> + <% if c[:count] > c[:samples].length %> + +<%= c[:count] - c[:samples].length %> + <% end %> +
+
+ <%= c[:count] %> photo<%= c[:count] == 1 ? '' : 's' %> +
+ + + <% if c[:slug] %> + + <% end %> +
+
+
+ <% end %> + <% if @total > @clusters.length %> +

+ Showing <%= @clusters.length %> of <%= @total %> clusters (named first, then largest unnamed). +

+ <% end %> +
+ <% end %> +
+ + diff --git a/views/layout.erb b/views/layout.erb index 086fe8c..e0c5399 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -17,8 +17,10 @@