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 /app.rb | |
| 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 'app.rb')
| -rw-r--r-- | app.rb | 177 |
1 files changed, 177 insertions, 0 deletions
@@ -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) |
