summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app.rb177
-rw-r--r--public/css/style.css43
-rw-r--r--scripts/cluster_faces.py223
-rw-r--r--views/admin/people.erb111
-rw-r--r--views/layout.erb2
-rw-r--r--views/people.erb25
-rw-r--r--views/person.erb26
7 files changed, 607 insertions, 0 deletions
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 @@
+<div class="admin-people">
+ <div class="admin-nav">
+ <a href="/admin/edit/" class="btn btn-sm">← Albums</a>
+ <% unless @clusters.empty? %>
+ <a href="/people" class="btn btn-sm" target="_blank">Public Page ↗</a>
+ <% end %>
+ </div>
+
+ <h1>People</h1>
+
+ <section class="admin-update">
+ <h2>Clustering</h2>
+ <p class="update-hint">
+ <%= @total %> cluster<%= @total == 1 ? '' : 's' %> &middot;
+ <%= @named_count %> named
+ <% if @updated_at %>&middot; last run <%= @updated_at %><% end %>
+ </p>
+ <p class="update-hint">
+ 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.
+ </p>
+ <button id="cluster-btn" class="btn" onclick="startRecluster()">Re-cluster Faces</button>
+ <div id="cluster-panel" class="update-panel hidden">
+ <div id="cluster-status" class="update-status"></div>
+ <pre id="cluster-log" class="update-log"></pre>
+ </div>
+ </section>
+
+ <% if @clusters.empty? %>
+ <p class="empty-album" style="margin-top: 40px">
+ No face data yet — the face daemon is still processing, or run Re-cluster once it has finished a pass.
+ </p>
+ <% else %>
+ <div class="people-admin-list">
+ <% @clusters.each do |c| %>
+ <div class="people-admin-row<%= c[:name] ? '' : ' unnamed-cluster' %>">
+ <div class="face-samples">
+ <% c[:samples].each do |s| %>
+ <img src="/face/<%= ERB::Util.html_escape(s[:rel]) %>?box=<%= ERB::Util.html_escape(s[:box].join(',')) %>"
+ width="72" height="72" loading="lazy">
+ <% end %>
+ <% if c[:count] > c[:samples].length %>
+ <span class="face-more">+<%= c[:count] - c[:samples].length %></span>
+ <% end %>
+ </div>
+ <div class="people-admin-meta">
+ <span class="face-count"><%= c[:count] %> photo<%= c[:count] == 1 ? '' : 's' %></span>
+ <form method="post" action="/admin/people/<%= ERB::Util.url_encode(c[:uuid]) %>" class="name-form">
+ <input type="text" name="name"
+ value="<%= ERB::Util.html_escape(c[:name].to_s) %>"
+ placeholder="Enter name…" class="name-input">
+ <button type="submit" class="btn btn-sm">Save</button>
+ <% if c[:slug] %>
+ <a href="/people/<%= ERB::Util.url_encode(c[:slug]) %>" class="btn btn-sm" target="_blank">↗</a>
+ <% end %>
+ </form>
+ </div>
+ </div>
+ <% end %>
+ <% if @total > @clusters.length %>
+ <p class="update-hint" style="margin-top: 14px">
+ Showing <%= @clusters.length %> of <%= @total %> clusters (named first, then largest unnamed).
+ </p>
+ <% end %>
+ </div>
+ <% end %>
+</div>
+
+<script>
+async function startRecluster() {
+ const btn = document.getElementById('cluster-btn');
+ const panel = document.getElementById('cluster-panel');
+ const log = document.getElementById('cluster-log');
+ const status = document.getElementById('cluster-status');
+
+ btn.disabled = true;
+ btn.textContent = 'Clustering…';
+ log.textContent = '';
+ status.textContent = 'Running…';
+ status.className = 'update-status running';
+ panel.classList.remove('hidden');
+
+ const res = await fetch('/admin/people/recluster', { method: 'POST' });
+ const { job_id } = await res.json();
+
+ let seen = 0;
+ const poll = setInterval(async () => {
+ const r = await fetch('/admin/update/' + job_id);
+ const data = await r.json();
+ const fresh = data.lines.slice(seen);
+ if (fresh.length) {
+ log.textContent += fresh.join('\n') + '\n';
+ log.scrollTop = log.scrollHeight;
+ seen = data.lines.length;
+ }
+ if (data.status !== 'running') {
+ clearInterval(poll);
+ btn.disabled = false;
+ btn.textContent = 'Re-cluster Faces';
+ if (data.status === 'done') {
+ status.textContent = 'Done ✓';
+ status.className = 'update-status done';
+ setTimeout(() => location.reload(), 1200);
+ } else {
+ status.textContent = 'Error ✗';
+ status.className = 'update-status error';
+ }
+ }
+ }, 1500);
+}
+</script>
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 @@
<header class="site-header">
<a href="/browse/" class="site-logo">Albumen</a>
<nav class="site-nav">
+ <% if FACES_ENABLED %><a href="/people">People</a><% end %>
<% if admin? %>
<a href="/admin/edit/<%= defined?(@rel) ? @rel : '' %>">Edit Album</a>
+ <% if FACES_ENABLED %><a href="/admin/people">Manage People</a><% end %>
<a href="/admin/logout">Logout</a>
<% else %>
<a href="/admin/login?return_to=<%= CGI.escape("/admin/edit/#{defined?(@rel) ? @rel : ''}") %>" class="nav-admin">Admin</a>
diff --git a/views/people.erb b/views/people.erb
new file mode 100644
index 0000000..4d47769
--- /dev/null
+++ b/views/people.erb
@@ -0,0 +1,25 @@
+<div class="album-header">
+ <h1>People</h1>
+</div>
+
+<% if @people.empty? %>
+ <p class="empty-album">
+ No named people yet.
+ <% if admin? %>
+ <a href="/admin/people">Go to Manage People</a> to name face clusters.
+ <% end %>
+ </p>
+<% else %>
+ <div class="grid people-grid">
+ <% @people.each do |p| %>
+ <a href="/people/<%= ERB::Util.url_encode(p[:slug]) %>" class="person-tile">
+ <div class="thumb-wrap">
+ <img src="/face/<%= ERB::Util.html_escape(p[:face][:rel]) %>?box=<%= ERB::Util.html_escape(p[:face][:box].join(',')) %>"
+ alt="<%= ERB::Util.html_escape(p[:name]) %>" loading="lazy">
+ </div>
+ <div class="person-name"><%= ERB::Util.html_escape(p[:name]) %></div>
+ <div class="person-count"><%= p[:count] %> photo<%= p[:count] == 1 ? '' : 's' %></div>
+ </a>
+ <% end %>
+ </div>
+<% end %>
diff --git a/views/person.erb b/views/person.erb
new file mode 100644
index 0000000..1bb25e2
--- /dev/null
+++ b/views/person.erb
@@ -0,0 +1,26 @@
+<div class="album-header">
+ <div class="breadcrumbs">
+ <a href="/people">People</a>
+ <span class="sep">/</span>
+ <%= ERB::Util.html_escape(@person_name) %>
+ </div>
+ <h1><%= ERB::Util.html_escape(@person_name) %></h1>
+ <p class="album-desc"><%= @count %> photo<%= @count == 1 ? '' : 's' %></p>
+</div>
+
+<% if @photos.empty? %>
+ <p class="empty-album">No photos found.</p>
+<% else %>
+ <div class="grid">
+ <% @photos.each do |photo| %>
+ <% album_url = photo[:dir_rel].empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(photo[:dir_rel])}" %>
+ <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(photo[:filename]) %>"
+ class="card" style="text-decoration:none">
+ <div class="thumb-wrap">
+ <img src="/thumb/<%= ERB::Util.html_escape(photo[:rel]) %>"
+ loading="lazy" alt="<%= ERB::Util.html_escape(photo[:filename]) %>">
+ </div>
+ </a>
+ <% end %>
+ </div>
+<% end %>