summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@jots.org>2026-06-09 13:09:23 +0000
committerKen D'Ambrosio <ken@jots.org>2026-06-09 13:09:23 +0000
commitc13a40a970be156a231200c20362636b198d32ec (patch)
tree9e7c0103d861bbd4dd5877d244431ca5ae07327f
parentcf1385bbd6d88a8db9f615512564e150c85a0b5f (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.rb52
-rw-r--r--public/css/style.css15
-rw-r--r--scripts/cluster_faces.py21
-rw-r--r--views/admin/people.erb20
-rw-r--r--views/admin/person_detail.erb59
5 files changed, 140 insertions, 27 deletions
diff --git a/app.rb b/app.rb
index 192814a..7ee7851 100644
--- a/app.rb
+++ b/app.rb
@@ -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 &mdash; <%= @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&hellip;</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 &mdash; 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 %>