diff options
| author | Ken D'Ambrosio <ken@jots.org> | 2026-06-09 13:09:23 +0000 |
|---|---|---|
| committer | Ken D'Ambrosio <ken@jots.org> | 2026-06-09 13:09:23 +0000 |
| commit | c13a40a970be156a231200c20362636b198d32ec (patch) | |
| tree | 9e7c0103d861bbd4dd5877d244431ca5ae07327f | |
| parent | cf1385bbd6d88a8db9f615512564e150c85a0b5f (diff) | |
Add face pool, blacklisting, and action explanations to people admin
Removed faces now go to an "Unidentified pool" cluster rather than
disappearing. Deleting a cluster blacklists all its members so they are
skipped by future re-clustering runs. Pool faces can be assigned to a
named person or individually blacklisted. A plain-English info box on
the detail page explains what each action does and that no photo files
are ever modified.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app.rb | 52 | ||||
| -rw-r--r-- | public/css/style.css | 15 | ||||
| -rw-r--r-- | scripts/cluster_faces.py | 21 | ||||
| -rw-r--r-- | views/admin/people.erb | 20 | ||||
| -rw-r--r-- | views/admin/person_detail.erb | 59 |
5 files changed, 140 insertions, 27 deletions
@@ -827,9 +827,16 @@ get '/admin/people' do @named_count = people.count { |_, p| p['name'] } @updated_at = data['updated_at'] + pool_data = people.delete('__pool__') + @pool = pool_data ? { count: (pool_data['members'] || []).length, + samples: (pool_data['members'] || []).first(6).map { |m| { rel: m['rel'], box: m['box'] } } } : nil + 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) } + @total = people.length + @named_count = people.count { |_, p| p['name'] } + @clusters = (named + unnamed).first(200).map do |uid, p| members = p['members'] || [] { uuid: uid, name: p['name'], slug: p['slug'], @@ -902,20 +909,21 @@ get '/admin/people/:uuid' do halt 404 unless people.key?(params[:uuid]) pd = people[params[:uuid]] - @title = pd['name'] || 'Unnamed cluster' + @is_pool = params[:uuid] == '__pool__' + @title = @is_pool ? 'Unidentified pool' : (pd['name'] || 'Unnamed cluster') @uuid = params[:uuid] @name = pd['name'] @members = pd['members'] || [] @count = @members.length - @named_others = people - .reject { |k, _| k == params[:uuid] } + regular = people.reject { |k, _| k == params[:uuid] || k == '__pool__' } + + @named_others = regular .select { |_, v| v['name'] } .map { |k, v| { uuid: k, name: v['name'] } } .sort_by { |x| x[:name].downcase } - @all_others = people - .reject { |k, _| k == params[:uuid] } + @all_others = regular .map { |k, v| { uuid: k, name: v['name'] || "(unnamed · #{(v['members'] || []).length})" } } .sort_by { |x| x[:name].downcase } @@ -939,8 +947,12 @@ post '/admin/people/:uuid/move' do halt 404 unless member people[src]['members'].delete(member) - if to == 'remove' - # just drop it — already deleted from src above + if to == 'blacklist' + data['blacklist'] ||= [] + data['blacklist'] << member + elsif to == 'remove' + people['__pool__'] ||= { 'name' => '__pool__', 'slug' => nil, 'members' => [] } + people['__pool__']['members'] << member elsif to == 'new' new_uid = SecureRandom.uuid people[new_uid] = { 'name' => nil, 'slug' => nil, 'members' => [member] } @@ -956,13 +968,31 @@ post '/admin/people/:uuid/move' do people[src] ? redirect("/admin/people/#{src}") : redirect('/admin/people') end -post '/admin/people/:uuid/delete' do +post '/admin/people/__pool__/blacklist_all' do require_admin! data = load_people_data people = data['people'] || {} - people.delete(params[:uuid]) - data['people'] = people - atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) + pool = people.delete('__pool__') + if pool + data['blacklist'] ||= [] + data['blacklist'].concat(pool['members'] || []) + data['people'] = people + atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) + end + redirect '/admin/people' +end + +post '/admin/people/:uuid/delete' do + require_admin! + data = load_people_data + people = data['people'] || {} + cluster = people.delete(params[:uuid]) + if cluster + data['blacklist'] ||= [] + data['blacklist'].concat(cluster['members'] || []) + data['people'] = people + atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) + end redirect '/admin/people' end diff --git a/public/css/style.css b/public/css/style.css index 64c19e3..877bdde 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -480,6 +480,21 @@ tr.delete-marked td { background: rgba(192,57,43,.08); } .name-input:focus { border-color: var(--accent); outline: none; } /* ── People — detail page ──────────────────────────────────────────────── */ +.people-info-box { + margin: 12px 0 24px; + padding: 10px 14px; + background: var(--bg2); border-left: 3px solid var(--accent); border-radius: var(--radius); + font-size: .85rem; color: var(--text-dim); line-height: 1.5; +} +.people-info-box em { color: var(--text-dim); } + +.people-pool-row { + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + padding: 12px 14px; margin-bottom: 16px; + background: var(--bg2); border: 1px solid #5a4a1a; border-radius: var(--radius); +} +.pool-label { color: #c8a84a; font-weight: 500; } + .face-detail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); diff --git a/scripts/cluster_faces.py b/scripts/cluster_faces.py index 92c64fe..a9492a9 100644 --- a/scripts/cluster_faces.py +++ b/scripts/cluster_faces.py @@ -35,7 +35,24 @@ 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.""" + """Return list of {rel, box, encoding} for all processed face instances. + + Faces that are in the pool or blacklist in people.json are skipped — + the user has explicitly handled them and they should not be re-clustered. + """ + skip = set() + people_path = os.path.join(media_root, 'people.json') + if os.path.exists(people_path): + try: + pd = json.load(open(people_path)) + for entry in pd.get('blacklist', []): + skip.add((entry['rel'], tuple(entry['box']))) + pool = pd.get('people', {}).get('__pool__', {}) + for m in pool.get('members', []): + skip.add((m['rel'], tuple(m['box']))) + except Exception: + pass + faces = [] for path in sorted(glob.glob(os.path.join(media_root, '**', 'faces.json'), recursive=True)): dir_abs = os.path.dirname(path) @@ -53,6 +70,8 @@ def collect_faces(media_root): if not enc or not box or len(enc) != 128: continue rel = f"{dir_rel}/{filename}" if dir_rel else filename + if (rel, tuple(box)) in skip: + continue faces.append({'rel': rel, 'box': box, 'encoding': enc}) return faces diff --git a/views/admin/people.erb b/views/admin/people.erb index c17e847..9715b95 100644 --- a/views/admin/people.erb +++ b/views/admin/people.erb @@ -26,6 +26,26 @@ </div> </section> + <% if @pool %> + <div class="people-pool-row"> + <div class="face-samples"> + <% @pool[: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" + class="face-list-thumb" + data-thumb="/thumb/<%= ERB::Util.html_escape(s[:rel]) %>"> + <% end %> + <% if @pool[:count] > @pool[:samples].length %> + <a href="/admin/people/__pool__" class="face-more">+<%= @pool[:count] - @pool[:samples].length %> more</a> + <% end %> + </div> + <div class="people-admin-meta"> + <span class="face-count pool-label">Unidentified pool — <%= @pool[:count] %> face<%= @pool[:count] == 1 ? '' : 's' %></span> + <a href="/admin/people/__pool__" class="btn btn-sm">Review</a> + </div> + </div> + <% end %> + <% 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. diff --git a/views/admin/person_detail.erb b/views/admin/person_detail.erb index 8a2f8fe..77f27b7 100644 --- a/views/admin/person_detail.erb +++ b/views/admin/person_detail.erb @@ -1,16 +1,30 @@ <div class="admin-people"> <div class="admin-nav"> <a href="/admin/people" class="btn btn-sm">← All Clusters</a> - <% if @name %> + <% if @name && !@is_pool %> <a href="/people/<%= ERB::Util.url_encode(@name.downcase.gsub(/[^a-z0-9]+/,'-').gsub(/^-+|-+$/,'')) %>" class="btn btn-sm" target="_blank">Public Page ↗</a> <% end %> </div> - <h1><%= @name ? ERB::Util.html_escape(@name) : 'Unnamed cluster' %></h1> + <h1><%= ERB::Util.html_escape(@title) %></h1> <p class="update-hint"><%= @count %> photo<%= @count == 1 ? '' : 's' %> in this cluster</p> - <%# ── Name ────────────────────────────────────────────────────────────── %> + <div class="people-info-box"> + <% if @is_pool %> + These faces were removed from other clusters and are waiting to be assigned. + Use <strong>Assign to…</strong> to move a face to a named person, or + <strong>Blacklist</strong> to permanently exclude it from future clustering. + <% else %> + <strong>Move to pool</strong> sends a face to a holding area where you can assign it later. + <strong>Blacklist cluster</strong> permanently excludes all faces in this cluster from future clustering. + <br> + <em>No photo files are ever modified — only the clustering metadata is affected.</em> + <% end %> + </div> + + <% unless @is_pool %> + <%# ── Name ──────────────────────────────────────────────────────────────── %> <section style="margin-bottom:20px"> <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>" class="name-form"> <input type="text" name="name" value="<%= ERB::Util.html_escape(@name.to_s) %>" @@ -19,7 +33,7 @@ </form> </section> - <%# ── Merge entire cluster ────────────────────────────────────────────── %> + <%# ── Merge entire cluster ──────────────────────────────────────────────── %> <% unless @all_others.empty? %> <section class="admin-update" style="margin-bottom:28px"> <h2>Merge entire cluster into another person</h2> @@ -38,19 +52,31 @@ </section> <% end %> - <%# ── Delete cluster ──────────────────────────────────────────────────── %> + <%# ── Delete cluster (blacklists all members) ───────────────────────────── %> <section style="margin-bottom:28px"> <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/delete" - onsubmit="return confirm('Delete this entire cluster (<%= @count %> photo<%= @count == 1 ? '' : 's' %>)? This cannot be undone.')"> - <button type="submit" class="btn btn-danger">Delete cluster</button> + onsubmit="return confirm('Blacklist this entire cluster (<%= @count %> photo<%= @count == 1 ? '' : 's' %>)? These faces will never be re-clustered.')"> + <button type="submit" class="btn btn-danger">Blacklist cluster</button> </form> </section> + <% else %> + <%# ── Pool: blacklist all ────────────────────────────────────────────────── %> + <section style="margin-bottom:28px"> + <form method="post" action="/admin/people/__pool__/blacklist_all" + onsubmit="return confirm('Blacklist all <%= @count %> faces in the pool? They will never be re-clustered.')"> + <button type="submit" class="btn btn-danger">Blacklist all in pool</button> + </form> + </section> + <% end %> - <%# ── Face grid ────────────────────────────────────────────────────────── %> + <%# ── Face grid ──────────────────────────────────────────────────────────── %> <p class="update-hint"> - Hover a face to see the full photo. - Click to open in the album. - <% unless @named_others.empty? %>Use the drop-down to move a face to another person.<% end %> + Hover a face to see the full photo. Click to open in the album. + <% if @is_pool %> + Use the drop-down to assign a face to a named person, or blacklist it permanently. + <% elsif !@named_others.empty? %> + Use the drop-down to move a face to another person. + <% end %> </p> <div class="face-detail-grid"> @@ -65,19 +91,22 @@ width="100" height="100" loading="lazy"> </a> </div> - <% unless @named_others.empty? %> + <% if @is_pool || !@named_others.empty? %> <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/move" class="face-move-form"> <input type="hidden" name="rel" value="<%= ERB::Util.html_escape(rel) %>"> <input type="hidden" name="box" value="<%= ERB::Util.html_escape(box.to_json) %>"> <select name="to" class="face-move-select" onchange="if(this.value) this.form.submit()"> - <option value="">Move to…</option> + <option value=""><%= @is_pool ? 'Assign to…' : 'Move to…' %></option> <% @named_others.each do |p| %> <option value="<%= ERB::Util.html_escape(p[:uuid]) %>"><%= ERB::Util.html_escape(p[:name]) %></option> <% end %> - <option value="new">New person</option> - <option value="remove">Remove face</option> + <% unless @is_pool %> + <option value="new">New person</option> + <option value="remove">Move to pool</option> + <% end %> + <option value="blacklist">Blacklist</option> </select> </form> <% end %> |
