Skip to content

Commit 9054483

Browse files
committed
v0.3.1: group unknowns by suggested person (ASSIGN ALL + per-face reject), pre-select dropdowns to the guess, and group person pickers by favorites
1 parent f436c5f commit 9054483

5 files changed

Lines changed: 181 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
All notable changes to FaceID. The Home Assistant add-on shows this file in the
44
update dialog; standalone users can watch GitHub releases.
55

6+
## 0.3.1 — 2026-07-23
7+
8+
- **Smarter review queue**: unknown faces that resemble an enrolled person are now
9+
grouped into one **"Looks like <name>"** card with a single **ASSIGN ALL** button —
10+
no more assigning the same person cluster by cluster. The person dropdown is
11+
pre-selected to the suggestion, and each face has a ✗ **"not this person"** button to
12+
pull it out if it doesn't belong. Remaining unrecognized clusters keep their own
13+
dropdown, pre-selected to the best guess.
14+
- **Grouped person dropdowns**: every person picker is now split into ★ Favorites and
15+
Others, alphabetically sorted.
16+
617
## 0.3.0 — 2026-07-23
718

819
- **Favorites & sorted person list**: mark people as favorites with the ★ button on

faceid-addon/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
All notable changes to FaceID. The Home Assistant add-on shows this file in the
44
update dialog; standalone users can watch GitHub releases.
55

6+
## 0.3.1 — 2026-07-23
7+
8+
- **Smarter review queue**: unknown faces that resemble an enrolled person are now
9+
grouped into one **"Looks like <name>"** card with a single **ASSIGN ALL** button —
10+
no more assigning the same person cluster by cluster. The person dropdown is
11+
pre-selected to the suggestion, and each face has a ✗ **"not this person"** button to
12+
pull it out if it doesn't belong. Remaining unrecognized clusters keep their own
13+
dropdown, pre-selected to the best guess.
14+
- **Grouped person dropdowns**: every person picker is now split into ★ Favorites and
15+
Others, alphabetically sorted.
16+
617
## 0.3.0 — 2026-07-23
718

819
- **Favorites & sorted person list**: mark people as favorites with the ★ button on

faceid-addon/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: FaceID
2-
version: "0.3.0"
2+
version: "0.3.1"
33
slug: faceid
44
description: Face recognition for Frigate — trainable gallery, clustered unknown review, HA sensors
55
url: https://github.com/SkyTechNerds/faceid

faceid-addon/static/index.html

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
font:12px var(--mono);padding:2px 6px;cursor:pointer;display:none}
3535
.tile:hover button.x,.tile:hover button.u{display:block}
3636
.cluster{border:1px solid var(--line);padding:10px;margin-bottom:14px}
37+
.cluster.sugg{border-color:var(--acc)}
38+
.cluster.sugg .head b{color:var(--acc)}
39+
.tile .x{position:absolute;top:0;right:0;background:var(--bg);border:0;color:var(--bad);font:12px var(--mono);padding:2px 6px;cursor:pointer}
3740
.cluster .head{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap}
3841
.cluster .head b{font:600 13px var(--mono);color:var(--fg)}
3942
.cluster .guess{font:12px var(--mono);color:var(--warn)}
@@ -99,7 +102,15 @@ <h1>FACEID</h1>
99102
async function health(){try{const h=await j('api/health');
100103
$('#health').textContent=`${h.persons} persons · queue ${h.queue} · events ${h.open_events}`}catch(e){$('#health').textContent='offline'}}
101104

102-
function personOptions(){return Object.entries(persons).map(([s,p])=>`<option value="${s}">${esc(p.name)}</option>`).join('')}
105+
function personOptions(sel){
106+
const es=Object.entries(persons).sort((a,b)=>a[1].name.localeCompare(b[1].name,undefined,{sensitivity:'base'}));
107+
const opt=([s,p])=>`<option value="${s}"${s===sel?' selected':''}>${esc(p.name)}</option>`;
108+
const favs=es.filter(([,p])=>p.favorite), rest=es.filter(([,p])=>!p.favorite);
109+
let out='';
110+
if(favs.length)out+=`<optgroup label="★ Favorites">${favs.map(opt).join('')}</optgroup>`;
111+
if(rest.length)out+=(favs.length?`<optgroup label="Others">${rest.map(opt).join('')}</optgroup>`:rest.map(opt).join(''));
112+
return out;
113+
}
103114

104115
async function render(){
105116
const t=tab;
@@ -144,17 +155,64 @@ <h1>FACEID</h1>
144155
if(bf.running&&tab==='unknowns')setTimeout(()=>{if(tab==='unknowns')render()},4000);
145156
if(!clusters.length){
146157
main.innerHTML=backfillRow(bf)+'<div class="empty">No unknown faces in the queue.</div>';return}
147-
main.innerHTML=backfillRow(bf)+`<div class="row">
158+
const excluded=window._excludedIds||(window._excludedIds=new Set());
159+
const n2s={}; Object.entries(persons).forEach(([sl,p])=>{n2s[p.name]=sl;});
160+
const SUGGEST=0.40;
161+
// Vorschlags-Gruppen (nach vermuteter Person) + Rest-Cluster
162+
const byName={};
163+
const rest=[];
164+
clusters.forEach(c=>{
165+
const keep=[];
166+
c.forEach(it=>{
167+
if(it.guess&&it.guess_score>=SUGGEST&&n2s[it.guess]&&!excluded.has(it.id))
168+
(byName[it.guess]=byName[it.guess]||[]).push(it);
169+
else keep.push(it);
170+
});
171+
if(keep.length)rest.push(keep);
172+
});
173+
const sugg=Object.keys(byName).sort((a,b)=>a.localeCompare(b,undefined,{sensitivity:'base'}))
174+
.map(name=>({name,slug:n2s[name],items:byName[name]}));
175+
window._sugg=sugg; window._clusters=rest; window._n2s=n2s;
176+
177+
let html=backfillRow(bf)+`<div class="row">
148178
<button class="act" title="assign every face whose best gallery match clears the recognition threshold" onclick="autoAssign()">APPLY SUGGESTIONS ≥50%</button>
149179
<span class="stat">assigns every face whose best gallery match clears the recognition threshold</span>
150-
</div>`+clusters.map((c,i)=>`
180+
</div>`;
181+
182+
// --- Vorschlags-Gruppen ---
183+
html+=sugg.map((g,gi)=>{
184+
const avg=Math.round(g.items.reduce((a,it)=>a+(it.guess_score||0),0)/g.items.length*100);
185+
return `
186+
<div class="cluster sugg" data-sg="${gi}">
187+
<div class="head">
188+
<b>★ Looks like ${esc(g.name)}</b>
189+
<span class="stat">${g.items.length} face(s) · avg ${avg}%</span>
190+
<span style="flex:1"></span>
191+
<select id="ssel${gi}" title="assign to a different person instead">${personOptions(g.slug)}</select>
192+
<button class="act" title="assign all faces in this group" onclick="assignSuggestion(${gi})">ASSIGN ALL</button>
193+
<button class="ghost" style="color:var(--warn);border-color:var(--warn)" title="never notify, match or resurface these faces" onclick="ignoreSuggestion(${gi})">ignore</button>
194+
</div>
195+
<div class="grid">${g.items.map(u=>`
196+
<div class="tile">
197+
<img src="data/unknowns/${u.id}.jpg" loading="lazy">
198+
<span class="score">${(u.guess_score*100|0)}% · ${fmtTs(u.ts)}</span>
199+
${u.full_url?`<button class="z" title="full snapshot" onclick="showFull('${u.full_url}','${esc(u.camera||'')} · ${fmtTs(u.ts)}','data/unknowns/${u.id}.jpg')">⛶</button>`:''}
200+
<button class="x" title="not ${esc(g.name)} — pull this face out" onclick="rejectSuggestion('${u.id}')">×</button>
201+
</div>`).join('')}
202+
</div>
203+
</div>`;}).join('');
204+
205+
// --- Rest-Cluster (unerkannt), Dropdown auf Guess vorgewaehlt ---
206+
html+=rest.map((c,i)=>{
207+
const gslug=(c[0].guess&&n2s[c[0].guess])?n2s[c[0].guess]:'';
208+
return `
151209
<div class="cluster" data-i="${i}">
152210
<div class="head">
153211
<b>CLUSTER ${String(i+1).padStart(2,'0')}</b>
154212
<span class="stat">${c.length} capture${c.length>1?'s':''} · ${esc(c[0].camera||'')}</span>
155213
${c[0].guess?`<span class="guess">looks like ${esc(c[0].guess)} (${(c[0].guess_score*100|0)}%)</span>`:''}
156214
<span style="flex:1"></span>
157-
<select id="sel${i}" title="assign to an existing person"><option value="">— pick person —</option>${personOptions()}</select>
215+
<select id="sel${i}" title="assign to an existing person"><option value="">— pick person —</option>${personOptions(gslug)}</select>
158216
<input id="new${i}" placeholder="…or new name" size="14" title="or create a new person with this name">
159217
<button class="act" title="assign selected tiles (or the whole cluster) to the chosen person" onclick="assignCluster(${i})">ASSIGN</button>
160218
<button class="ghost" style="color:var(--warn);border-color:var(--warn)" onclick="ignoreCluster(${i})" title="never notify, match or resurface these faces">ignore</button>
@@ -167,9 +225,24 @@ <h1>FACEID</h1>
167225
${u.full_url?`<button class="z" title="full snapshot" onclick="event.stopPropagation();showFull('${u.full_url}','${esc(u.camera||'')} · ${fmtTs(u.ts)}','data/unknowns/${u.id}.jpg')">⛶</button>`:''}
168226
</div>`).join('')}
169227
</div>
170-
</div>`).join('');
171-
window._clusters=clusters;
228+
</div>`;}).join('');
229+
main.innerHTML=html;
230+
}
231+
async function assignSuggestion(gi){
232+
const g=window._sugg[gi]; if(!g)return;
233+
const slug=$(`#ssel${gi}`).value||g.slug;
234+
const ids=g.items.map(it=>it.id).filter(id=>!window._excludedIds.has(id));
235+
if(!ids.length)return toast('Nothing to assign');
236+
const r=await j('api/unknowns/assign',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,person:slug})});
237+
toast(`${r.assigned} face(s) assigned`);render();
238+
}
239+
async function ignoreSuggestion(gi){
240+
const g=window._sugg[gi]; if(!g)return;
241+
const ids=g.items.map(it=>it.id).filter(id=>!window._excludedIds.has(id));
242+
const r=await j('api/unknowns/ignore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,person:'-'})});
243+
toast(`${r.ignored} ignored`);render();
172244
}
245+
function rejectSuggestion(id){(window._excludedIds||(window._excludedIds=new Set())).add(id);render();}
173246
async function renderIgnored(){
174247
const clusters=await j('api/ignored');
175248
if(tab!=='ignored')return;

static/index.html

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
font:12px var(--mono);padding:2px 6px;cursor:pointer;display:none}
3535
.tile:hover button.x,.tile:hover button.u{display:block}
3636
.cluster{border:1px solid var(--line);padding:10px;margin-bottom:14px}
37+
.cluster.sugg{border-color:var(--acc)}
38+
.cluster.sugg .head b{color:var(--acc)}
39+
.tile .x{position:absolute;top:0;right:0;background:var(--bg);border:0;color:var(--bad);font:12px var(--mono);padding:2px 6px;cursor:pointer}
3740
.cluster .head{display:flex;align-items:center;gap:10px;margin-bottom:8px;flex-wrap:wrap}
3841
.cluster .head b{font:600 13px var(--mono);color:var(--fg)}
3942
.cluster .guess{font:12px var(--mono);color:var(--warn)}
@@ -99,7 +102,15 @@ <h1>FACEID</h1>
99102
async function health(){try{const h=await j('api/health');
100103
$('#health').textContent=`${h.persons} persons · queue ${h.queue} · events ${h.open_events}`}catch(e){$('#health').textContent='offline'}}
101104

102-
function personOptions(){return Object.entries(persons).map(([s,p])=>`<option value="${s}">${esc(p.name)}</option>`).join('')}
105+
function personOptions(sel){
106+
const es=Object.entries(persons).sort((a,b)=>a[1].name.localeCompare(b[1].name,undefined,{sensitivity:'base'}));
107+
const opt=([s,p])=>`<option value="${s}"${s===sel?' selected':''}>${esc(p.name)}</option>`;
108+
const favs=es.filter(([,p])=>p.favorite), rest=es.filter(([,p])=>!p.favorite);
109+
let out='';
110+
if(favs.length)out+=`<optgroup label="★ Favorites">${favs.map(opt).join('')}</optgroup>`;
111+
if(rest.length)out+=(favs.length?`<optgroup label="Others">${rest.map(opt).join('')}</optgroup>`:rest.map(opt).join(''));
112+
return out;
113+
}
103114

104115
async function render(){
105116
const t=tab;
@@ -144,17 +155,64 @@ <h1>FACEID</h1>
144155
if(bf.running&&tab==='unknowns')setTimeout(()=>{if(tab==='unknowns')render()},4000);
145156
if(!clusters.length){
146157
main.innerHTML=backfillRow(bf)+'<div class="empty">No unknown faces in the queue.</div>';return}
147-
main.innerHTML=backfillRow(bf)+`<div class="row">
158+
const excluded=window._excludedIds||(window._excludedIds=new Set());
159+
const n2s={}; Object.entries(persons).forEach(([sl,p])=>{n2s[p.name]=sl;});
160+
const SUGGEST=0.40;
161+
// Vorschlags-Gruppen (nach vermuteter Person) + Rest-Cluster
162+
const byName={};
163+
const rest=[];
164+
clusters.forEach(c=>{
165+
const keep=[];
166+
c.forEach(it=>{
167+
if(it.guess&&it.guess_score>=SUGGEST&&n2s[it.guess]&&!excluded.has(it.id))
168+
(byName[it.guess]=byName[it.guess]||[]).push(it);
169+
else keep.push(it);
170+
});
171+
if(keep.length)rest.push(keep);
172+
});
173+
const sugg=Object.keys(byName).sort((a,b)=>a.localeCompare(b,undefined,{sensitivity:'base'}))
174+
.map(name=>({name,slug:n2s[name],items:byName[name]}));
175+
window._sugg=sugg; window._clusters=rest; window._n2s=n2s;
176+
177+
let html=backfillRow(bf)+`<div class="row">
148178
<button class="act" title="assign every face whose best gallery match clears the recognition threshold" onclick="autoAssign()">APPLY SUGGESTIONS ≥50%</button>
149179
<span class="stat">assigns every face whose best gallery match clears the recognition threshold</span>
150-
</div>`+clusters.map((c,i)=>`
180+
</div>`;
181+
182+
// --- Vorschlags-Gruppen ---
183+
html+=sugg.map((g,gi)=>{
184+
const avg=Math.round(g.items.reduce((a,it)=>a+(it.guess_score||0),0)/g.items.length*100);
185+
return `
186+
<div class="cluster sugg" data-sg="${gi}">
187+
<div class="head">
188+
<b>★ Looks like ${esc(g.name)}</b>
189+
<span class="stat">${g.items.length} face(s) · avg ${avg}%</span>
190+
<span style="flex:1"></span>
191+
<select id="ssel${gi}" title="assign to a different person instead">${personOptions(g.slug)}</select>
192+
<button class="act" title="assign all faces in this group" onclick="assignSuggestion(${gi})">ASSIGN ALL</button>
193+
<button class="ghost" style="color:var(--warn);border-color:var(--warn)" title="never notify, match or resurface these faces" onclick="ignoreSuggestion(${gi})">ignore</button>
194+
</div>
195+
<div class="grid">${g.items.map(u=>`
196+
<div class="tile">
197+
<img src="data/unknowns/${u.id}.jpg" loading="lazy">
198+
<span class="score">${(u.guess_score*100|0)}% · ${fmtTs(u.ts)}</span>
199+
${u.full_url?`<button class="z" title="full snapshot" onclick="showFull('${u.full_url}','${esc(u.camera||'')} · ${fmtTs(u.ts)}','data/unknowns/${u.id}.jpg')">⛶</button>`:''}
200+
<button class="x" title="not ${esc(g.name)} — pull this face out" onclick="rejectSuggestion('${u.id}')">×</button>
201+
</div>`).join('')}
202+
</div>
203+
</div>`;}).join('');
204+
205+
// --- Rest-Cluster (unerkannt), Dropdown auf Guess vorgewaehlt ---
206+
html+=rest.map((c,i)=>{
207+
const gslug=(c[0].guess&&n2s[c[0].guess])?n2s[c[0].guess]:'';
208+
return `
151209
<div class="cluster" data-i="${i}">
152210
<div class="head">
153211
<b>CLUSTER ${String(i+1).padStart(2,'0')}</b>
154212
<span class="stat">${c.length} capture${c.length>1?'s':''} · ${esc(c[0].camera||'')}</span>
155213
${c[0].guess?`<span class="guess">looks like ${esc(c[0].guess)} (${(c[0].guess_score*100|0)}%)</span>`:''}
156214
<span style="flex:1"></span>
157-
<select id="sel${i}" title="assign to an existing person"><option value="">— pick person —</option>${personOptions()}</select>
215+
<select id="sel${i}" title="assign to an existing person"><option value="">— pick person —</option>${personOptions(gslug)}</select>
158216
<input id="new${i}" placeholder="…or new name" size="14" title="or create a new person with this name">
159217
<button class="act" title="assign selected tiles (or the whole cluster) to the chosen person" onclick="assignCluster(${i})">ASSIGN</button>
160218
<button class="ghost" style="color:var(--warn);border-color:var(--warn)" onclick="ignoreCluster(${i})" title="never notify, match or resurface these faces">ignore</button>
@@ -167,9 +225,24 @@ <h1>FACEID</h1>
167225
${u.full_url?`<button class="z" title="full snapshot" onclick="event.stopPropagation();showFull('${u.full_url}','${esc(u.camera||'')} · ${fmtTs(u.ts)}','data/unknowns/${u.id}.jpg')">⛶</button>`:''}
168226
</div>`).join('')}
169227
</div>
170-
</div>`).join('');
171-
window._clusters=clusters;
228+
</div>`;}).join('');
229+
main.innerHTML=html;
230+
}
231+
async function assignSuggestion(gi){
232+
const g=window._sugg[gi]; if(!g)return;
233+
const slug=$(`#ssel${gi}`).value||g.slug;
234+
const ids=g.items.map(it=>it.id).filter(id=>!window._excludedIds.has(id));
235+
if(!ids.length)return toast('Nothing to assign');
236+
const r=await j('api/unknowns/assign',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,person:slug})});
237+
toast(`${r.assigned} face(s) assigned`);render();
238+
}
239+
async function ignoreSuggestion(gi){
240+
const g=window._sugg[gi]; if(!g)return;
241+
const ids=g.items.map(it=>it.id).filter(id=>!window._excludedIds.has(id));
242+
const r=await j('api/unknowns/ignore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,person:'-'})});
243+
toast(`${r.ignored} ignored`);render();
172244
}
245+
function rejectSuggestion(id){(window._excludedIds||(window._excludedIds=new Set())).add(id);render();}
173246
async function renderIgnored(){
174247
const clusters=await j('api/ignored');
175248
if(tab!=='ignored')return;

0 commit comments

Comments
 (0)