dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel.
This commit is contained in:
@@ -49,3 +49,5 @@ rating based pose, thumbs up/down find good/bad poses easier
|
|||||||
(pairs well with the new pose index — could weight similar-pose results by rating)
|
(pairs well with the new pose index — could weight similar-pose results by rating)
|
||||||
|
|
||||||
when refresh page, we lose track of current jobs running.
|
when refresh page, we lose track of current jobs running.
|
||||||
|
|
||||||
|
generating poses themself should use the adviced dimensions rather than the base image reference.
|
||||||
@@ -1834,7 +1834,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="lbFaceBook" style="display:none;margin-bottom:8px">
|
<div id="lbFaceBook" style="display:none;margin-bottom:8px">
|
||||||
<div class="sb-label" style="margin-bottom:4px">Face reference</div>
|
<div class="sb-label" style="margin-bottom:4px">Face reference</div>
|
||||||
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full">
|
<div style="display:flex;gap:8px;align-items:flex-start">
|
||||||
|
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer;flex-shrink:0" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full">
|
||||||
|
<button class="sb-btn" onclick="lbFindSimilarFaces()" title="Find groups with matching faces">Similar faces</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-sep"></div>
|
<div class="sb-sep"></div>
|
||||||
<div class="sb-label">Order & visibility</div>
|
<div class="sb-label">Order & visibility</div>
|
||||||
@@ -2968,6 +2971,86 @@
|
|||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function lbFindSimilarFaces() {
|
||||||
|
if (!lbCurrentGid) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/faces/similar`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ group_id: lbCurrentGid, limit: 12 }),
|
||||||
|
});
|
||||||
|
if (r.status === 404) { _renderFaceResults(null); return; }
|
||||||
|
if (!r.ok) { showToast('Similar faces failed: ' + await r.text(), 'error'); return; }
|
||||||
|
const d = await r.json();
|
||||||
|
_renderFaceResults(d.similar || []);
|
||||||
|
} catch (e) { showToast('Similar faces failed: ' + e, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lbBuildFaceIndex() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/faces/index`, { method: 'POST' });
|
||||||
|
if (!r.ok) { showToast('Face index failed: ' + await r.text(), 'error'); return; }
|
||||||
|
showToast('Building face index…', 'info');
|
||||||
|
_pollFaceIndex();
|
||||||
|
} catch (e) { showToast('Face index failed: ' + e, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _pollFaceIndex() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/faces/index/status`);
|
||||||
|
if (r.ok) {
|
||||||
|
const s = await r.json();
|
||||||
|
if (s.running) {
|
||||||
|
showToast(`Face index: ${s.done}/${s.total}…`, 'info', 2500);
|
||||||
|
setTimeout(_pollFaceIndex, 2000);
|
||||||
|
} else {
|
||||||
|
showToast(`Face index ready (${s.indexed} embeddings)`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderFaceResults(items) {
|
||||||
|
document.getElementById('faceResults')?.remove();
|
||||||
|
const viewer = document.getElementById('studioViewer');
|
||||||
|
if (!viewer) return;
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = 'faceResults';
|
||||||
|
panel.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:103;background:rgba(0,0,0,0.85);'
|
||||||
|
+ 'padding:8px;display:flex;gap:8px;overflow-x:auto;align-items:center;';
|
||||||
|
if (!items || !items.length) {
|
||||||
|
const msg = items === null
|
||||||
|
? 'No face embedding for this group — build the index first'
|
||||||
|
: 'No similar faces found';
|
||||||
|
panel.innerHTML = `<span style="color:#aaa;font-size:12px;flex:1">${msg}</span>`
|
||||||
|
+ '<button class="sb-btn" onclick="lbBuildFaceIndex();document.getElementById(\'faceResults\')?.remove()">Build index</button>'
|
||||||
|
+ '<button class="sb-btn" onclick="document.getElementById(\'faceResults\')?.remove()">Close</button>';
|
||||||
|
} else {
|
||||||
|
panel.innerHTML = '<span style="color:#aaa;font-size:11px;flex-shrink:0">Similar faces:</span>'
|
||||||
|
+ items.map(it => {
|
||||||
|
const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now();
|
||||||
|
const gid = (it.group_id || '').replace(/'/g, "\\'");
|
||||||
|
const fn = it.filename.replace(/'/g, "\\'");
|
||||||
|
return `<div title="dist ${it.distance}" style="flex-shrink:0;cursor:pointer;text-align:center"
|
||||||
|
onclick="openFaceResult('${gid}','${fn}')">
|
||||||
|
<img src="${u}" loading="lazy" style="width:64px;height:64px;object-fit:cover;border-radius:5px;border:1px solid #444" onerror="this.style.opacity='0.3'">
|
||||||
|
<div style="font-size:9px;color:#777">${it.distance}</div></div>`;
|
||||||
|
}).join('')
|
||||||
|
+ '<button class="sb-btn" style="flex-shrink:0" onclick="document.getElementById(\'faceResults\')?.remove()">Close</button>';
|
||||||
|
}
|
||||||
|
viewer.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFaceResult(gid, fname) {
|
||||||
|
document.getElementById('faceResults')?.remove();
|
||||||
|
if (gid && groupData.has(gid)) {
|
||||||
|
openStudio(gid, 0);
|
||||||
|
} else {
|
||||||
|
const i = lbNames.indexOf(fname);
|
||||||
|
if (i >= 0) { lbIdx = i; updateStudio(); }
|
||||||
|
else showToast('Group not found in gallery: ' + fname, 'info', 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _renderPoseResults(items) {
|
function _renderPoseResults(items) {
|
||||||
document.getElementById('poseResults')?.remove();
|
document.getElementById('poseResults')?.remove();
|
||||||
const viewer = document.getElementById('studioViewer');
|
const viewer = document.getElementById('studioViewer');
|
||||||
@@ -5236,19 +5319,15 @@
|
|||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
html += `<div class="scene-frame-preview" id="sceneFramePreview" style="${_sceneVideo||_sceneFrameBytes?'':'display:none'}">
|
html += `<div class="scene-frame-preview" id="sceneFramePreview" style="${_sceneVideo||_sceneFrameBytes?'':'display:none'}">
|
||||||
<video id="sceneVideoEl" muted preload="auto" playsinline autoplay loop
|
<video id="sceneVideoEl" muted preload="auto" playsinline loop controls
|
||||||
style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"></video>
|
style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"></video>
|
||||||
<img id="sceneFrameImg" style="${_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"
|
<img id="sceneFrameImg" style="${_sceneFrameBytes?'':'display:none'};width:100%;height:100%;object-fit:contain"
|
||||||
${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/>
|
${_sceneFrameBytes?`src="data:image/png;base64,${_sceneFrameBytes}"`:''}/>
|
||||||
<div id="sceneFrameLoading" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;font-size:11px;color:#aaa">Loading…</div>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="range" class="scene-scrubber" id="sceneScrubber"
|
|
||||||
min="0" max="${_sceneDuration||100}" value="0" step="0.1"
|
|
||||||
oninput="sceneSeekVideo(this.value)"
|
|
||||||
style="${_sceneVideo?'':'display:none'}">
|
|
||||||
<div id="sceneTimeLabel" style="font-size:10px;color:#555;margin-bottom:4px;${_sceneVideo?'':'display:none'}">0:00</div>
|
|
||||||
<button class="sb-btn" id="sceneExtractBtn" onclick="sceneExtractFrame()"
|
<button class="sb-btn" id="sceneExtractBtn" onclick="sceneExtractFrame()"
|
||||||
style="${_sceneVideo?'':'display:none'};margin-bottom:8px">Extract frame as reference</button>
|
style="${_sceneVideo&&!_sceneFrameBytes?'':'display:none'};margin-bottom:8px">Extract frame as reference</button>
|
||||||
|
<button class="sb-btn" id="sceneReleaseBtn" onclick="sceneReleaseFrame()"
|
||||||
|
style="${_sceneFrameBytes?'':'display:none'};margin-bottom:8px">✖ Change frame</button>
|
||||||
<div class="sb-sep"></div>
|
<div class="sb-sep"></div>
|
||||||
<div class="sb-label">Or upload image</div>
|
<div class="sb-label">Or upload image</div>
|
||||||
<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)">
|
<input type="file" id="sceneUploadInput" accept="image/*" style="display:none" onchange="sceneHandleUpload(event)">
|
||||||
@@ -5268,10 +5347,9 @@
|
|||||||
if (_sceneVideo) {
|
if (_sceneVideo) {
|
||||||
const vid = document.getElementById('sceneVideoEl');
|
const vid = document.getElementById('sceneVideoEl');
|
||||||
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
|
vid.src = `${API}/wireframe/${encodeURIComponent(_sceneVideo)}`;
|
||||||
if (_sceneDuration) {
|
vid.addEventListener('seeking', () => {
|
||||||
const scrubber = document.getElementById('sceneScrubber');
|
if (_sceneFrameBytes) sceneReleaseFrame();
|
||||||
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Cards show static poster frames; videos mount only on hover (_tplPlay).
|
// Cards show static poster frames; videos mount only on hover (_tplPlay).
|
||||||
}
|
}
|
||||||
@@ -5279,58 +5357,20 @@
|
|||||||
async function sceneSelectVideo(v) {
|
async function sceneSelectVideo(v) {
|
||||||
_sceneVideo = v; _sceneFrameBytes = null;
|
_sceneVideo = v; _sceneFrameBytes = null;
|
||||||
renderSidebarScenery();
|
renderSidebarScenery();
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/wireframe/duration/${encodeURIComponent(v)}`);
|
|
||||||
if (r.ok) {
|
|
||||||
_sceneDuration = (await r.json()).duration || 0;
|
|
||||||
const scrubber = document.getElementById('sceneScrubber');
|
|
||||||
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _sceneScrubTimer = null;
|
function sceneReleaseFrame() {
|
||||||
|
_sceneFrameBytes = null;
|
||||||
function sceneSeekVideo(val) {
|
|
||||||
const t = parseFloat(val);
|
|
||||||
const lbl = document.getElementById('sceneTimeLabel');
|
|
||||||
if (lbl) lbl.textContent = formatSecs(t);
|
|
||||||
// If a captured frame was locked, release it so scrubbing works again
|
|
||||||
if (_sceneFrameBytes) {
|
|
||||||
_sceneFrameBytes = null;
|
|
||||||
const btn = document.getElementById('sceneExtractBtn');
|
|
||||||
if (btn) btn.textContent = 'Extract frame as reference';
|
|
||||||
const genBtn = document.getElementById('sceneGenBtn');
|
|
||||||
if (genBtn) genBtn.disabled = !_sceneVideo;
|
|
||||||
}
|
|
||||||
// Show video live while the user is dragging
|
|
||||||
const vid = document.getElementById('sceneVideoEl');
|
const vid = document.getElementById('sceneVideoEl');
|
||||||
const img = document.getElementById('sceneFrameImg');
|
const img = document.getElementById('sceneFrameImg');
|
||||||
if (vid) { vid.style.display = ''; vid.currentTime = t; }
|
const extractBtn = document.getElementById('sceneExtractBtn');
|
||||||
|
const releaseBtn = document.getElementById('sceneReleaseBtn');
|
||||||
|
const genBtn = document.getElementById('sceneGenBtn');
|
||||||
|
if (vid) vid.style.display = '';
|
||||||
if (img) img.style.display = 'none';
|
if (img) img.style.display = 'none';
|
||||||
// After settling, fetch exact server-rendered frame and freeze on it
|
if (extractBtn) extractBtn.style.display = '';
|
||||||
clearTimeout(_sceneScrubTimer);
|
if (releaseBtn) releaseBtn.style.display = 'none';
|
||||||
_sceneScrubTimer = setTimeout(() => _sceneShowFrame(t), 300);
|
if (genBtn) genBtn.disabled = !_sceneVideo;
|
||||||
}
|
|
||||||
|
|
||||||
async function _sceneShowFrame(t) {
|
|
||||||
if (!_sceneVideo) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/wireframe/frame`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ video_name: _sceneVideo, time: t }),
|
|
||||||
});
|
|
||||||
if (!r.ok) return;
|
|
||||||
const d = await r.json();
|
|
||||||
const vid = document.getElementById('sceneVideoEl');
|
|
||||||
const img = document.getElementById('sceneFrameImg');
|
|
||||||
if (!img) return;
|
|
||||||
if (vid) vid.style.display = 'none';
|
|
||||||
img.src = 'data:image/png;base64,' + d.frame_b64;
|
|
||||||
img.style.display = '';
|
|
||||||
img.dataset.pendingFrame = d.frame_b64; // ready to capture without re-fetch
|
|
||||||
} catch (_) { /* video stays visible on error */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sceneExtractFrame() {
|
async function sceneExtractFrame() {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ def migrate_schema():
|
|||||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS content_type TEXT DEFAULT 'image'",
|
"ALTER TABLE person ADD COLUMN IF NOT EXISTS content_type TEXT DEFAULT 'image'",
|
||||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS faceswap_source_video TEXT",
|
"ALTER TABLE person ADD COLUMN IF NOT EXISTS faceswap_source_video TEXT",
|
||||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE person ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE",
|
||||||
|
"ALTER TABLE person ADD COLUMN IF NOT EXISTS face_embedding vector(512)",
|
||||||
]:
|
]:
|
||||||
cur.execute(sql)
|
cur.execute(sql)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -122,16 +123,18 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
|
|||||||
embedding=None, clip_description=None, prompt=None, pose=None,
|
embedding=None, clip_description=None, prompt=None, pose=None,
|
||||||
sort_order=None, group_name=None, hidden=None,
|
sort_order=None, group_name=None, hidden=None,
|
||||||
has_background=None, source_refs=None, has_clothing=None,
|
has_background=None, source_refs=None, has_clothing=None,
|
||||||
content_type=None, faceswap_source_video=None, archived=None):
|
content_type=None, faceswap_source_video=None, archived=None,
|
||||||
|
face_embedding=None):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
face_embedding_str = ("[" + ",".join(map(str, face_embedding)) + "]") if face_embedding is not None else None
|
||||||
try:
|
try:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO person (filename, filepath, name, group_id, tags, embedding,
|
INSERT INTO person (filename, filepath, name, group_id, tags, embedding,
|
||||||
clip_description, prompt, pose, sort_order, group_name, hidden,
|
clip_description, prompt, pose, sort_order, group_name, hidden,
|
||||||
has_background, source_refs, has_clothing,
|
has_background, source_refs, has_clothing,
|
||||||
content_type, faceswap_source_video, archived)
|
content_type, faceswap_source_video, archived, face_embedding)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
ON CONFLICT (filename) DO UPDATE
|
ON CONFLICT (filename) DO UPDATE
|
||||||
SET filepath = COALESCE(EXCLUDED.filepath, person.filepath),
|
SET filepath = COALESCE(EXCLUDED.filepath, person.filepath),
|
||||||
name = COALESCE(EXCLUDED.name, person.name),
|
name = COALESCE(EXCLUDED.name, person.name),
|
||||||
@@ -149,12 +152,13 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
|
|||||||
has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing),
|
has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing),
|
||||||
content_type = COALESCE(EXCLUDED.content_type, person.content_type),
|
content_type = COALESCE(EXCLUDED.content_type, person.content_type),
|
||||||
faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video),
|
faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video),
|
||||||
archived = COALESCE(EXCLUDED.archived, person.archived);
|
archived = COALESCE(EXCLUDED.archived, person.archived),
|
||||||
|
face_embedding = COALESCE(EXCLUDED.face_embedding, person.face_embedding);
|
||||||
""", (filename, filepath, name, group_id,
|
""", (filename, filepath, name, group_id,
|
||||||
json.dumps(tags) if tags else None,
|
json.dumps(tags) if tags else None,
|
||||||
embedding, clip_description, prompt, pose, sort_order, group_name, hidden,
|
embedding, clip_description, prompt, pose, sort_order, group_name, hidden,
|
||||||
has_background, source_refs, has_clothing,
|
has_background, source_refs, has_clothing,
|
||||||
content_type, faceswap_source_video, archived))
|
content_type, faceswap_source_video, archived, face_embedding_str))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -330,3 +334,55 @@ def get_all_group_names():
|
|||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def get_face_embedding(filename):
|
||||||
|
"""Return the face_embedding as a list of floats for a filename, or None."""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("SELECT face_embedding FROM person WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0] is not None:
|
||||||
|
val = row[0]
|
||||||
|
# psycopg2 without a pgvector adapter returns vectors as plain strings "[f,f,...]"
|
||||||
|
if isinstance(val, str):
|
||||||
|
return [float(x) for x in val.strip("[]").split(",")]
|
||||||
|
return list(val)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def search_similar_face(embedding, limit=12, exclude_group_id=None):
|
||||||
|
"""Cosine search on face_embedding (stored only for *_face.png rows).
|
||||||
|
|
||||||
|
Returns [(filename, group_id, distance), ...] sorted ascending by distance.
|
||||||
|
Rows belonging to exclude_group_id are skipped so a group doesn't match itself.
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
embedding_str = "[" + ",".join(map(str, embedding)) + "]"
|
||||||
|
if exclude_group_id:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT filename, group_id, face_embedding <=> %s AS distance
|
||||||
|
FROM person
|
||||||
|
WHERE face_embedding IS NOT NULL
|
||||||
|
AND (group_id IS NULL OR group_id != %s)
|
||||||
|
ORDER BY distance ASC
|
||||||
|
LIMIT %s
|
||||||
|
""", (embedding_str, exclude_group_id, limit))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT filename, group_id, face_embedding <=> %s AS distance
|
||||||
|
FROM person
|
||||||
|
WHERE face_embedding IS NOT NULL
|
||||||
|
ORDER BY distance ASC
|
||||||
|
LIMIT %s
|
||||||
|
""", (embedding_str, limit))
|
||||||
|
return cur.fetchall()
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
_put_db_connection(conn)
|
||||||
|
|||||||
@@ -1486,6 +1486,7 @@ def list_videos():
|
|||||||
@app.get("/wireframe/frame/{video_name}")
|
@app.get("/wireframe/frame/{video_name}")
|
||||||
def wireframe_frame(video_name: str, t: float = 0.5):
|
def wireframe_frame(video_name: str, t: float = 0.5):
|
||||||
"""Extract a single frame at normalized time t (0–1) from a wireframe video. Returns PNG."""
|
"""Extract a single frame at normalized time t (0–1) from a wireframe video. Returns PNG."""
|
||||||
|
import cv2
|
||||||
wireframe_dir = _load_wireframe_dir()
|
wireframe_dir = _load_wireframe_dir()
|
||||||
video_path = os.path.join(wireframe_dir, video_name)
|
video_path = os.path.join(wireframe_dir, video_name)
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
@@ -1916,10 +1917,12 @@ def _extract_face_bg(filename: str, fpath: str):
|
|||||||
face_fname = f"{gid_tag}_face.png"
|
face_fname = f"{gid_tag}_face.png"
|
||||||
face_path = os.path.join(os.path.dirname(fpath), face_fname)
|
face_path = os.path.join(os.path.dirname(fpath), face_fname)
|
||||||
cropped.save(face_path)
|
cropped.save(face_path)
|
||||||
|
face_embed = face.normed_embedding.tolist() if hasattr(face, 'normed_embedding') and face.normed_embedding is not None else None
|
||||||
database.upsert_person(face_fname, filepath=face_path, group_id=group_id,
|
database.upsert_person(face_fname, filepath=face_path, group_id=group_id,
|
||||||
name=person[0] if person else None,
|
name=person[0] if person else None,
|
||||||
source_refs=json.dumps([filename]))
|
source_refs=json.dumps([filename]),
|
||||||
print(f"[extract-face] saved {face_fname}")
|
face_embedding=face_embed)
|
||||||
|
print(f"[extract-face] saved {face_fname}" + (" + face embedding" if face_embed else ""))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[extract-face] error for {filename}: {e}")
|
print(f"[extract-face] error for {filename}: {e}")
|
||||||
|
|
||||||
@@ -2120,6 +2123,88 @@ def extract_face_endpoint(filename: str):
|
|||||||
return {"status": "queued", "filename": filename}
|
return {"status": "queued", "filename": filename}
|
||||||
|
|
||||||
|
|
||||||
|
class FaceSimilarRequest(BaseModel):
|
||||||
|
group_id: str
|
||||||
|
limit: int = 12
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/faces/similar")
|
||||||
|
def face_similar(req: FaceSimilarRequest):
|
||||||
|
"""Find groups with visually similar faces using insightface embeddings.
|
||||||
|
|
||||||
|
Looks up the face embedding stored for {group_id}_face.png and returns
|
||||||
|
the top-N closest matches from other groups.
|
||||||
|
"""
|
||||||
|
face_fname = f"{req.group_id.replace('/', '_')}_face.png"
|
||||||
|
embedding = database.get_face_embedding(face_fname)
|
||||||
|
if embedding is None:
|
||||||
|
raise HTTPException(404, "No face embedding found for this group — set a preferred image first")
|
||||||
|
|
||||||
|
rows = database.search_similar_face(embedding, limit=req.limit, exclude_group_id=req.group_id)
|
||||||
|
# Each row is (filename, group_id, distance). Return the group thumbnail filename
|
||||||
|
# (the _face.png itself) so the frontend can render it directly.
|
||||||
|
results = [
|
||||||
|
{"filename": r[0], "group_id": r[1], "distance": round(float(r[2]), 4)}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"similar": results}
|
||||||
|
|
||||||
|
|
||||||
|
_face_index_status: dict = {"running": False, "done": 0, "total": 0, "indexed": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _face_index_worker():
|
||||||
|
"""Backfill face embeddings for all *_face.png files that lack one."""
|
||||||
|
global _face_index_status
|
||||||
|
output_dir = _load_output_dir()
|
||||||
|
face_files = [f for f in os.listdir(output_dir) if f.endswith("_face.png")]
|
||||||
|
_face_index_status.update({"running": True, "done": 0, "total": len(face_files), "indexed": 0})
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
app_fa, _ = _load_faceswapper()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[face-index] failed to load insightface: {e}")
|
||||||
|
_face_index_status["running"] = False
|
||||||
|
return
|
||||||
|
indexed = 0
|
||||||
|
for i, fname in enumerate(face_files):
|
||||||
|
existing = database.get_face_embedding(fname)
|
||||||
|
if existing is not None:
|
||||||
|
_face_index_status["done"] = i + 1
|
||||||
|
continue
|
||||||
|
fpath = os.path.join(output_dir, fname)
|
||||||
|
try:
|
||||||
|
bgr = cv2.imread(fpath)
|
||||||
|
if bgr is None:
|
||||||
|
continue
|
||||||
|
faces = app_fa.get(bgr)
|
||||||
|
if not faces:
|
||||||
|
continue
|
||||||
|
face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
|
||||||
|
if not hasattr(face, 'normed_embedding') or face.normed_embedding is None:
|
||||||
|
continue
|
||||||
|
database.upsert_person(fname, face_embedding=face.normed_embedding.tolist())
|
||||||
|
indexed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[face-index] {fname}: {e}")
|
||||||
|
_face_index_status.update({"done": i + 1, "indexed": indexed})
|
||||||
|
_face_index_status["running"] = False
|
||||||
|
print(f"[face-index] done: {indexed}/{len(face_files)} embeddings stored")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/faces/index")
|
||||||
|
def build_face_index():
|
||||||
|
if _face_index_status.get("running"):
|
||||||
|
return {"status": "already_running", **_face_index_status}
|
||||||
|
threading.Thread(target=_face_index_worker, daemon=True).start()
|
||||||
|
return {"status": "started"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/faces/index/status")
|
||||||
|
def face_index_status():
|
||||||
|
return _face_index_status
|
||||||
|
|
||||||
|
|
||||||
@app.get("/faces/{group_id}")
|
@app.get("/faces/{group_id}")
|
||||||
def face_status(group_id: str):
|
def face_status(group_id: str):
|
||||||
"""Report whether a face crop exists for a group.
|
"""Report whether a face crop exists for a group.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user