diff options
| author | Ken D'Ambrosio <ken@jots.org> | 2026-06-08 19:00:02 +0000 |
|---|---|---|
| committer | Ken D'Ambrosio <ken@jots.org> | 2026-06-08 19:00:02 +0000 |
| commit | 01f52565f460a0107679999588b73b770f01a98c (patch) | |
| tree | 806c723ad62221f176fd97d5fdcaadac5d8da2d4 /views/admin | |
| parent | 625b3d5176f2c274e91fcf28bda8e45cc0477722 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'views/admin')
| -rw-r--r-- | views/admin/people.erb | 111 |
1 files changed, 111 insertions, 0 deletions
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' %> · + <%= @named_count %> named + <% if @updated_at %>· 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> |
