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:
mike
2026-06-24 21:27:11 +02:00
parent 8df588e594
commit 54d96ef580
10 changed files with 1178 additions and 369 deletions

View File

@@ -1,64 +0,0 @@
#!/bin/bash
# One-time (idempotent) host setup for the Qwen-Image-Edit service.
# Runs as the service user (NO sudo). Safe to re-run: existing pieces are skipped.
#
# Builds, under the project BASE (the parent of this api/ dir):
# venv/ torch 2.3.1+rocm5.7 + ComfyUI deps (gfx906 / ROCm 5.7)
# ComfyUI/ pinned to v0.3.77 + ComfyUI-GGUF custom node
# ComfyUI/models/{unet,text_encoders,vae}/ the v23 Q8 GGUF + encoder + vae
set -e
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
GGUF_NODE="$COMFY/custom_nodes/ComfyUI-GGUF"
COMFY_TAG="v0.3.77" # newest tag that runs on torch 2.3.1 (no comfy_kitchen)
echo "[bootstrap] BASE=$BASE VENV=$VENV"
# --- ComfyUI (pinned) -------------------------------------------------------
if [ ! -d "$COMFY/.git" ]; then
echo "[bootstrap] cloning ComfyUI @ $COMFY_TAG ..."
git clone --depth 1 --branch "$COMFY_TAG" \
https://github.com/comfyanonymous/ComfyUI.git "$COMFY"
fi
# --- venv + python deps -----------------------------------------------------
if [ ! -d "$VENV" ]; then
echo "[bootstrap] creating venv at $VENV ..."
python3 -m venv "$VENV"
fi
source "$VENV/bin/activate"
python -m pip install --upgrade pip wheel
echo "[bootstrap] installing torch (rocm5.7) ..."
pip install torch==2.3.1+rocm5.7 torchvision==0.18.1+rocm5.7 \
--index-url https://download.pytorch.org/whl/rocm5.7
echo "[bootstrap] installing ComfyUI requirements ..."
pip install -r "$COMFY/requirements.txt"
# --- ComfyUI-GGUF custom node ----------------------------------------------
if [ ! -d "$GGUF_NODE" ]; then
echo "[bootstrap] cloning ComfyUI-GGUF ..."
git clone --depth 1 https://github.com/city96/ComfyUI-GGUF.git "$GGUF_NODE"
fi
pip install -r "$GGUF_NODE/requirements.txt" || pip install gguf
# --- API deps ---------------------------------------------------------------
pip install fastapi "uvicorn[standard]" websocket-client python-multipart pillow requests
# --- models (resume-safe; skipped if already complete) ----------------------
M="$COMFY/models"
mkdir -p "$M/unet" "$M/text_encoders" "$M/vae"
dl() { # url dest
if [ -s "$2" ]; then echo "[bootstrap] have $(basename "$2")"; return; fi
echo "[bootstrap] downloading $(basename "$2") ..."
wget -c -q -O "$2" "$1"
}
dl "https://huggingface.co/Novice25/Qwen-Image-Edit-Rapid-AIO-GGUF/resolve/main/v23/Qwen-Rapid-NSFW-v23_Q8_0.gguf" \
"$M/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" \
"$M/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" \
"$M/vae/qwen_image_vae.safetensors"
echo "[bootstrap] verifying torch + GPU ..."
python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO GPU')"
echo "[bootstrap] BOOTSTRAP_DONE"

View File

@@ -1459,6 +1459,13 @@
font-size: 9px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
display: flex; align-items: center; justify-content: center; line-height: 1;
}
.sb-gen-refs .sb-gen-ref-x {
position: absolute; top: -4px; right: -4px; background: #dc2626; color: #fff;
font-size: 11px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
display: none; align-items: center; justify-content: center; line-height: 1;
}
.sb-gen-refs .sb-gen-ref:hover .sb-gen-ref-x { display: flex; }
.sb-gen-refs .sb-gen-ref:hover img { border-color: #dc2626; }
#studioAngleBar {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s;
@@ -1490,7 +1497,9 @@
}
.sb-template-card:hover { border-color: #7c3aed; transform: scale(1.02); }
.sb-template-card.selected { border-color: #22c55e; }
.sb-template-card video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
.sb-template-card video,
.sb-template-card img { width:100%; height:100%; object-fit:cover; pointer-events:none; }
.sb-template-card video { position:absolute; inset:0; }
.sb-template-label {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent,rgba(0,0,0,0.82));
@@ -1845,11 +1854,13 @@
<div class="sb-actions" style="margin-bottom:8px">
<button class="sb-btn" id="lbExtract" style="display:none" onclick="extractCurrentImage()">Extract</button>
<button class="sb-btn" id="lbNoBgBtn" onclick="lbRemoveBg()">No BG</button>
<button class="sb-btn" id="lbInvertAlphaBtn" onclick="lbInvertAlpha()" title="Invert transparency — fixes when the wrong segment was kept">Invert α</button>
<button class="sb-btn" id="lbCropBtn" style="display:none" onclick="lbAutoCrop()" title="Crop away transparent border">Crop</button>
<button class="sb-btn" id="lbDuplicateBtn" onclick="lbDuplicate()" title="Duplicate image into same group">Duplicate</button>
<button class="sb-btn" id="lbManualCropBtn" onclick="startManualCrop()" title="Drag to crop image">Crop…</button>
<button class="sb-btn" id="lbRotateLeftBtn" onclick="lbRotate(-90)" title="Rotate 90° counter-clockwise">⟲ 90°</button>
<button class="sb-btn" id="lbRotateRightBtn" onclick="lbRotate(90)" title="Rotate 90° clockwise">⟳ 90°</button>
<button class="sb-btn" id="lbPoseBtn" onclick="lbTogglePose()" title="Preview body pose skeleton">Pose</button>
<a class="sb-btn" id="lbDownloadBtn" download style="text-decoration:none">Download</a>
<button class="sb-btn" id="lbArchiveBtn" onclick="lbArchive()" style="color:#f59e0b" title="Move to archive (recoverable)">Archive</button>
<button class="sb-btn danger" id="lbDeleteBtn" onclick="lbDeleteArm()" title="Click once to arm, then again to confirm">Delete</button>
@@ -2330,6 +2341,13 @@
document.getElementById('studio').classList.remove('open');
document.body.style.overflow = '';
stopSlideshow();
// Release every streaming/decoding video so closing the studio frees memory
// and stops background network streams (template clips + scenery preview).
_tplStopAll();
['sceneVideoEl', 'lbVideo', 'cltVideoPreview'].forEach(id => {
const v = document.getElementById(id);
if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
});
}
function toggleSlideshow() {
@@ -2391,6 +2409,8 @@
function updateStudio() {
const fname = lbNames[lbIdx];
if (document.getElementById('poseCanvas')) _removePoseOverlay(); // skeleton is per-image
document.getElementById('poseResults')?.remove();
_fsModelFilename = fname; // keep faceswap/scenery/segment in sync
const isVid = isVideo(fname) || fileContentType[fname] === 'video';
const lbImgEl = document.getElementById('lbImg');
@@ -2442,6 +2462,11 @@
if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none';
const cropBtn = document.getElementById('lbCropBtn');
if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
// Invert α is only meaningful on a transparent image (after background removal).
const invAlphaBtn = document.getElementById('lbInvertAlphaBtn');
if (invAlphaBtn) invAlphaBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none';
const poseBtn = document.getElementById('lbPoseBtn');
if (poseBtn) poseBtn.style.display = isVid ? 'none' : '';
const archiveBtn = document.getElementById('lbArchiveBtn');
if (archiveBtn) archiveBtn.textContent = fileArchived[fname] ? 'Restore' : 'Archive';
@@ -2767,7 +2792,6 @@
showToast('Rotated', 'success');
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
updateStudio();
refreshNow();
} else {
showToast('Rotate failed: ' + await r.text(), 'error');
}
@@ -2775,6 +2799,239 @@
btns.forEach(b => { if (b) b.disabled = false; });
}
// --- body-pose skeleton preview (interactive) ---
const POSE_MIN_SCORE = 0.3;
const POSE_KP_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#a855f7'];
function _removePoseOverlay() {
document.getElementById('poseCanvas')?.remove();
document.getElementById('poseToolbar')?.remove();
window._poseState = null;
const btn = document.getElementById('lbPoseBtn');
if (btn) btn.classList.remove('primary');
}
async function lbTogglePose() {
if (document.getElementById('poseCanvas')) { _removePoseOverlay(); return; }
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image', 'info'); return; }
const btn = document.getElementById('lbPoseBtn');
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' });
if (r.status === 501) {
showToast('Pose estimator not installed on the server (pip install rtmlib onnxruntime)', 'info', 6000);
} else if (r.ok) {
const d = await r.json();
if (!d.people || !d.people.length) showToast('No person detected', 'info');
else { _initPoseOverlay(d, fname); showToast(`Pose (${d.backend}) — drag joints to edit`, 'success'); }
} else {
showToast('Pose failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Pose failed: ' + e, 'error'); }
if (btn) { btn.disabled = false; btn.textContent = 'Pose'; }
}
function _initPoseOverlay(data, fname) {
_removePoseOverlay();
const viewer = document.getElementById('studioViewer');
const img = document.getElementById('lbImg');
if (!viewer || !img) return;
const canvas = document.createElement('canvas');
canvas.id = 'poseCanvas';
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:grab;z-index:90;';
viewer.appendChild(canvas);
canvas.width = viewer.clientWidth;
canvas.height = viewer.clientHeight;
// object-fit:contain letterbox mapping image px ↔ canvas px.
const iW = data.width, iH = data.height;
const scale = Math.min(canvas.width / iW, canvas.height / iH);
const st = window._poseState = {
fname, canvas, ctx: canvas.getContext('2d'),
people: data.people.map(p => p.map(k => k.slice())), // deep copy (editable)
skeleton: data.skeleton, width: iW, height: iH,
scale, offX: (canvas.width - iW * scale) / 2, offY: (canvas.height - iH * scale) / 2,
drag: null,
};
// Floating toolbar (top-left so it doesn't fight the crop bar on the right).
const bar = document.createElement('div');
bar.id = 'poseToolbar';
bar.style.cssText = 'position:absolute;top:8px;left:8px;display:flex;gap:6px;z-index:102;';
bar.innerHTML =
'<button class="sb-btn" onclick="lbPoseReset()" title="Re-detect the original pose">Reset</button>'
+ '<button class="sb-btn primary" onclick="lbFindSimilarPose()" title="Find library images in this pose">Similar pose</button>';
viewer.appendChild(bar);
canvas.addEventListener('mousedown', _posePointerDown);
canvas.addEventListener('mousemove', _posePointerMove);
window.addEventListener('mouseup', _posePointerUp);
_drawPose();
const btn = document.getElementById('lbPoseBtn');
if (btn) btn.classList.add('primary');
}
function _drawPose() {
const st = window._poseState; if (!st) return;
const { ctx, canvas, scale, offX, offY } = st;
const cx = x => offX + x * scale, cy = y => offY + y * scale;
ctx.clearRect(0, 0, canvas.width, canvas.height);
st.people.forEach((kpts, pi) => {
const color = POSE_KP_COLORS[pi % POSE_KP_COLORS.length];
ctx.lineWidth = 3; ctx.strokeStyle = color;
st.skeleton.forEach(([a, b]) => {
const pa = kpts[a], pb = kpts[b];
if (!pa || !pb || pa[2] < POSE_MIN_SCORE || pb[2] < POSE_MIN_SCORE) return;
ctx.beginPath(); ctx.moveTo(cx(pa[0]), cy(pa[1])); ctx.lineTo(cx(pb[0]), cy(pb[1])); ctx.stroke();
});
kpts.forEach((p, ki) => {
if (!p || p[2] < POSE_MIN_SCORE) return;
const hot = st.drag && st.drag.pi === pi && st.drag.ki === ki;
ctx.beginPath(); ctx.arc(cx(p[0]), cy(p[1]), hot ? 7 : 5, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = color; ctx.stroke();
});
});
}
function _poseHitTest(mx, my) {
const st = window._poseState; if (!st) return null;
const cx = x => st.offX + x * st.scale, cy = y => st.offY + y * st.scale;
let best = null, bestD = 14 * 14; // ~14px grab radius
st.people.forEach((kpts, pi) => kpts.forEach((p, ki) => {
if (!p || p[2] < POSE_MIN_SCORE) return;
const dx = mx - cx(p[0]), dy = my - cy(p[1]), d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = { pi, ki }; }
}));
return best;
}
function _poseEvtXY(e) {
const r = window._poseState.canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function _posePointerDown(e) {
const st = window._poseState; if (!st) return;
const [mx, my] = _poseEvtXY(e);
const hit = _poseHitTest(mx, my);
if (hit) { st.drag = hit; st.canvas.style.cursor = 'grabbing'; _drawPose(); e.preventDefault(); }
}
function _posePointerMove(e) {
const st = window._poseState; if (!st) return;
if (!st.drag) {
const [mx, my] = _poseEvtXY(e);
st.canvas.style.cursor = _poseHitTest(mx, my) ? 'grab' : 'default';
return;
}
const [mx, my] = _poseEvtXY(e);
const p = st.people[st.drag.pi][st.drag.ki];
p[0] = Math.max(0, Math.min(st.width, (mx - st.offX) / st.scale));
p[1] = Math.max(0, Math.min(st.height, (my - st.offY) / st.scale));
if (p[2] < POSE_MIN_SCORE) p[2] = 1.0; // dragging a hidden joint makes it visible
_drawPose();
}
function _posePointerUp() {
const st = window._poseState; if (!st || !st.drag) return;
st.drag = null; st.canvas.style.cursor = 'grab'; _drawPose();
}
async function lbPoseReset() {
const st = window._poseState; if (!st) return;
const fname = st.fname;
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' });
if (r.ok) { const d = await r.json(); if (d.people?.length) _initPoseOverlay(d, fname); }
} catch (e) { showToast('Reset failed: ' + e, 'error'); }
}
async function lbFindSimilarPose() {
const st = window._poseState; if (!st) return;
// Use the (possibly edited) primary skeleton.
const kpts = st.people[0];
const btn = event?.target;
if (btn) btn.disabled = true;
try {
const r = await fetch(`${API}/pose/similar`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keypoints: kpts, width: st.width, height: st.height, limit: 12 }),
});
if (r.ok) {
const d = await r.json();
_renderPoseResults(d.similar || []);
} else if (r.status === 400) {
showToast('Pose too sparse — drag more joints into place', 'info');
} else {
showToast('Similar failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Similar failed: ' + e, 'error'); }
if (btn) btn.disabled = false;
}
function _renderPoseResults(items) {
document.getElementById('poseResults')?.remove();
const viewer = document.getElementById('studioViewer');
if (!viewer) return;
const panel = document.createElement('div');
panel.id = 'poseResults';
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.length) {
panel.innerHTML = '<span style="color:#aaa;font-size:12px;flex:1">No similar poses found — try building the pose index.</span>'
+ '<button class="sb-btn" onclick="lbBuildPoseIndex()">Build index</button>'
+ '<button class="sb-btn" onclick="document.getElementById(\'poseResults\')?.remove()">Close</button>';
} else {
panel.innerHTML = '<span style="color:#aaa;font-size:11px;flex-shrink:0">Similar poses:</span>'
+ items.map(it => {
const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now();
return `<div title="dist ${it.distance}" style="flex-shrink:0;cursor:pointer;text-align:center"
onclick="openPoseResult('${(it.group_id||'').replace(/'/g,"\\'")}','${it.filename.replace(/'/g,"\\'")}')">
<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(\'poseResults\')?.remove()">Close</button>';
}
viewer.appendChild(panel);
}
function openPoseResult(gid, fname) {
_removePoseOverlay();
document.getElementById('poseResults')?.remove();
if (gid && groupData.has(gid)) {
const i = groupData.get(gid).names.indexOf(fname);
openStudio(gid, i >= 0 ? i : 0); // openStudio sets lbUrls/lbNames/lbIdx + updateStudio
} else {
const i = lbNames.indexOf(fname);
if (i >= 0) { lbIdx = i; updateStudio(); }
else showToast('Open its group from the gallery: ' + fname, 'info', 5000);
}
}
async function lbBuildPoseIndex() {
try {
const r = await fetch(`${API}/pose/index`, { method: 'POST' });
if (r.status === 501) { showToast('Pose estimator not installed', 'info'); return; }
if (!r.ok) { showToast('Index failed: ' + await r.text(), 'error'); return; }
showToast('Building pose index…', 'info');
_pollPoseIndex();
} catch (e) { showToast('Index failed: ' + e, 'error'); }
}
async function _pollPoseIndex() {
try {
const r = await fetch(`${API}/pose/index/status`);
if (r.ok) {
const s = await r.json();
if (s.running) {
showToast(`Pose index: ${s.done}/${s.total}`, 'info', 2500);
setTimeout(_pollPoseIndex, 2000);
} else {
showToast(`Pose index ready (${s.indexed} images)`, 'success');
}
}
} catch (e) { /* stop polling */ }
}
function startManualCrop() {
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; }
@@ -2930,23 +3187,54 @@
async function lbRemoveBg() {
const fname = lbNames[lbIdx];
showToast(`Removing background for ${fname}...`);
const btn = document.getElementById('lbNoBgBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Removing…'; }
showToast('Removing background…');
try {
const r = await fetch(`${API}/remove-background/${fname}`, { method: 'POST' });
if (r.ok) {
showToast(`Background removed for ${fname}`, 'success');
// Refresh current image in lightbox by adding timestamp
const img = document.getElementById('lbImg');
img.src = img.src.split('?')[0] + '?t=' + Date.now();
refreshNow();
showToast('Background removed', 'success');
fileHasBg[fname] = false;
_bustStudioImage(fname); // cache-busts viewer + calls updateStudio()
} else {
showToast(`Failed to remove background for ${fname}`, 'error');
}
} catch (e) {
showToast(`Failed to remove background: ${e}`, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'No BG'; }
}
}
async function lbInvertAlpha() {
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|webp)$/i.test(fname)) { showToast('Invert needs a transparent PNG', 'info'); return; }
const btn = document.getElementById('lbInvertAlphaBtn');
if (btn) btn.disabled = true;
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/invert-alpha`, { method: 'POST' });
if (r.ok) {
showToast('Transparency inverted', 'success');
fileHasBg[fname] = false;
_bustStudioImage(fname);
refreshNow();
} else {
showToast('Invert failed: ' + await r.text(), 'error');
}
} catch (e) { showToast('Invert failed: ' + e, 'error'); }
if (btn) btn.disabled = false;
}
// Force the studio image (filmstrip + main viewer) to reload from disk.
function _bustStudioImage(fname) {
const t = Date.now();
const i = lbNames.indexOf(fname);
if (i >= 0) lbUrls[i] = IMAGE_FOLDER + fname + '?t=' + t;
const img = document.getElementById('lbImg');
if (img && lbNames[lbIdx] === fname) img.src = IMAGE_FOLDER + fname + '?t=' + t;
updateStudio();
}
async function deleteGroup(el, event) {
if (event) event.stopPropagation();
const gid = el.closest('.image-card').dataset.group;
@@ -3018,8 +3306,9 @@
showToast(`Error uploading ${fileName}: ${e}`, 'error');
}
}
// Trigger refresh
refreshNow();
// The source image is registered by a background task slightly after /upload
// returns; one debounced refresh picks it up without piling up concurrent loads.
setTimeout(refreshNow, 800);
}
// --- clipboard paste support ---
@@ -3131,6 +3420,8 @@
}
async function loadImages() {
if (loadImages._inFlight) return;
loadImages._inFlight = true;
const gallery = document.getElementById('gallery');
const statusDot = document.getElementById('statusDot');
statusDot.classList.add('updating');
@@ -3204,6 +3495,7 @@
</div>`;
}
statusDot.classList.remove('updating');
loadImages._inFlight = false;
return;
}
@@ -3311,14 +3603,27 @@
if (fresh) {
const curName = lbNames[lbIdx];
const newIdx = fresh.names.indexOf(curName);
// Snapshot ref filenames before overwriting lbNames
const oldRefNames = _sbRefIndices.map(i => lbNames[i]).filter(Boolean);
lbUrls = fresh.urls;
lbNames = fresh.names;
lbIdx = newIdx >= 0 ? newIdx : Math.min(lbIdx, fresh.names.length - 1);
// Keep lbIdx stable when current image not yet in fresh data
// (e.g. duplicate just created — server JSON may lag by the debounce window).
if (newIdx >= 0) {
lbIdx = newIdx;
} else if (lbIdx >= fresh.names.length) {
lbIdx = fresh.names.length - 1;
}
// Re-map multi-ref selections to new positions by filename
_sbRefIndices = oldRefNames
.map(n => fresh.names.indexOf(n))
.filter(i => i >= 0);
updateStudio();
}
}
statusDot.classList.remove('updating');
loadImages._inFlight = false;
}
// Discover images by trying common patterns (fallback for file:// protocol)
@@ -3365,8 +3670,13 @@
if (!_isStudioOpen()) refreshNow();
}
// Debounced refresh — coalesces multiple rapid calls into one loadImages() run.
// Direct callers that need a response (pollJob completion, etc.) still use
// loadImages() directly; everything else should go through refreshNow().
let _refreshTimer = null;
function refreshNow() {
loadImages();
clearTimeout(_refreshTimer);
_refreshTimer = setTimeout(loadImages, 80);
}
const API = 'http://127.0.0.1:8500';
@@ -3711,7 +4021,7 @@
const data = groupData.get(lbCurrentGid);
if (!data) return;
const filename = data.names[lbIdx];
const btn = document.querySelector('#lightbox .lb-btn[onclick="tagCurrentImage()"]');
const btn = document.querySelector('button[onclick="tagCurrentImage()"]');
if (btn) btn.textContent = '…';
try {
const r = await fetch(`${API}/tag`, {
@@ -3732,7 +4042,7 @@
showToast(`Tag failed: ${err}`, 'info');
}
} catch (e) { showToast('API not reachable', 'info'); }
if (btn) btn.textContent = 'Tag';
if (btn) btn.textContent = 'Re-tag';
}
async function extractCurrentImage() {
@@ -3840,17 +4150,24 @@
}
if (!document.getElementById('studio').classList.contains('open')) return;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') { lbNav(-1); e.preventDefault(); }
if (e.key === 'ArrowRight') { lbNav(1); e.preventDefault(); }
if (e.key === 'Home') {
lbIdx = 0;
updateStudio();
e.preventDefault();
// Throttle filmstrip navigation — ignore if last nav was < 80 ms ago.
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const now = Date.now();
if (now - (document._lbNavLast || 0) >= 80) {
document._lbNavLast = now;
lbNav(e.key === 'ArrowLeft' ? -1 : 1);
}
e.preventDefault();
}
if (e.key === 'End') {
lbIdx = lbUrls.length - 1;
updateStudio();
e.preventDefault();
if (e.key === 'Home') {
lbIdx = 0;
updateStudio();
e.preventDefault();
}
if (e.key === 'End') {
lbIdx = lbUrls.length - 1;
updateStudio();
e.preventDefault();
}
if (e.key === ' ') {
const vid = document.getElementById('lbVideo');
@@ -3859,22 +4176,24 @@
e.preventDefault();
}
}
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { lbToggleHidden(); }
if (e.key === 'F' || e.key === 'f') {
lbSetPreferred();
e.preventDefault();
// Guard async actions against concurrent executions via a simple inflight flag.
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') {
if (!document._lbHideInFlight) { document._lbHideInFlight = true; lbToggleHidden().finally(() => { document._lbHideInFlight = false; }); }
}
if (e.key === 'Delete') {
lbArchive();
e.preventDefault();
if (e.key === 'F' || e.key === 'f') {
lbSetPreferred();
e.preventDefault();
}
if (e.key === 'ArrowUp') {
lbMoveInGroup(-1);
e.preventDefault();
if (e.key === 'Delete') {
lbArchive();
e.preventDefault();
}
if (e.key === 'ArrowDown') {
lbMoveInGroup(1);
e.preventDefault();
// ArrowUp/Down move images within the group — debounce rapid keypresses.
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
clearTimeout(document._lbMoveTimer);
const d = e.key === 'ArrowUp' ? -1 : 1;
document._lbMoveTimer = setTimeout(() => lbMoveInGroup(d), 120);
e.preventDefault();
}
});
});
@@ -3955,7 +4274,7 @@
async function setAsPreferred(filename, btn) {
try {
const r = await fetch(`/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
const r = await fetch(`${API}/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
if (!r.ok) { console.error('set-preferred failed', r.status); return; }
const data = await r.json();
// Update sort orders locally
@@ -4008,6 +4327,7 @@
}
function switchSidebarTab(tab, initial) {
_tplStopAll(); // release any hovered template video before re-rendering panels
_activeSidebarTab = tab;
localStorage.setItem('studioSidebarTab', tab);
document.querySelectorAll('#studioSidebar .sb-tab').forEach(t =>
@@ -4143,9 +4463,9 @@
const fn = lbNames[idx] || '';
const u = lbUrls[idx] || '';
const thumb = isVideo(fn) ? posterFor(u) : u;
return `<div class="sb-gen-ref" title="${escHtml(fn)}"><img src="${thumb}" loading="lazy" onerror="this.style.opacity='0.3'"><div class="sb-gen-ref-badge">${pos+1}</div></div>`;
return `<div class="sb-gen-ref" title="Click to remove · ${escHtml(fn)}" style="cursor:pointer" onclick="sbRemoveRef(${idx})"><img src="${thumb}" loading="lazy" onerror="this.style.opacity='0.3'"><div class="sb-gen-ref-badge">${pos+1}</div><div class="sb-gen-ref-x">×</div></div>`;
}).join('');
html += `<div class="sb-label" style="margin-bottom:4px">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip to change</span></div>
html += `<div class="sb-label" style="margin-bottom:4px">References <span style="color:#555;font-weight:400;font-size:9px">· Shift+Click filmstrip or click a thumb to remove</span></div>
<div class="sb-gen-refs">${refThumbs}</div>`;
// With 23 refs, let the user choose how the prompt is applied.
if (_sbRefIndices.length >= 2) {
@@ -4326,6 +4646,15 @@
renderSidebarGenerate();
}
// Remove a reference by clicking its thumbnail above the custom prompt — no page
// refresh needed (previously refs could only be cleared via Shift+Click filmstrip).
function sbRemoveRef(idx) {
const pos = _sbRefIndices.indexOf(idx);
if (pos >= 0) _sbRefIndices.splice(pos, 1);
updateStudio();
if (_activeSidebarTab === 'generate') renderSidebarGenerate();
}
function sbSelectWireframe(val) {
_sbWireframeRef = val;
renderSidebarGenerate();
@@ -4543,6 +4872,53 @@
}
}
// ---- template video cards (faceswap / scenery) ----
// The wireframe library is large (dozens of files, >1 GB total) and lives on a
// network mount. Rendering every card as an autoplaying <video> spun up that many
// simultaneous decoders + range-streams, which OOM-crashed the renderer. Instead
// each card shows a static poster frame (a small server-rendered PNG) and only
// mounts a single real <video> while hovered — at most one decoder alive at a time.
// HTML for one template card. `extraHTML` lets faceswap add its trim button.
function _tplCardHTML(name, selected, onclickExpr, extraHTML = '') {
const stem = name.replace(/\.[^.]+$/, '');
const sel = selected ? ' selected' : '';
const nSafe = name.replace(/'/g, "\\'");
return `<div class="sb-template-card${sel}" onclick="${onclickExpr}"
onmouseenter="_tplPlay(this,'${nSafe}')" onmouseleave="_tplStop(this)">
<img src="${API}/wireframe/frame/${encodeURIComponent(name)}?t=0" loading="lazy" alt="">
<div class="sb-template-label">${escHtml(stem)}</div>${extraHTML}
</div>`;
}
// Mount + play a single <video> over the poster while hovered.
function _tplPlay(card, name) {
if (card._vid) return;
const v = document.createElement('video');
v.src = `${API}/wireframe/${encodeURIComponent(name)}`;
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
card.appendChild(v);
card._vid = v;
v.play().catch(() => {});
}
// Tear the video down completely so the decoder + network stream are released.
function _tplStop(card) {
const v = card && card._vid;
if (!v) return;
v.pause();
v.removeAttribute('src');
v.load(); // aborts the in-flight stream and frees the decoder
v.remove();
card._vid = null;
}
// Defensive: stop every active template video in the sidebar (called on tab switch
// so a hovered clip can't keep streaming under a freshly-rendered panel).
function _tplStopAll() {
document.querySelectorAll('#studioSidebar .sb-template-card').forEach(_tplStop);
}
// ---- faceswap sidebar tab ----
async function loadTemplateVideos() {
@@ -4612,17 +4988,10 @@
} else {
html += '<div class="sb-template-grid">';
availableVideos.forEach(v => {
const stem = v.replace(/\.[^.]+$/, '');
const vSafe = v.replace(/'/g, "\\'");
const sel = _fsSelectedVideo === v ? ' selected' : '';
html += `<div class="sb-template-card${sel}" onclick="sbSelectTemplate('${vSafe}')"
onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop playsinline
preload="metadata" style="pointer-events:none;width:100%;height:100%;object-fit:cover"></video>
<div class="sb-template-label">${escHtml(stem)}</div>
<button class="sb-template-trim-btn" title="Trim video"
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>
</div>`;
const trimBtn = `<button class="sb-template-trim-btn" title="Trim video"
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>`;
html += _tplCardHTML(v, _fsSelectedVideo === v, `sbSelectTemplate('${vSafe}')`, trimBtn);
});
html += '</div>';
}
@@ -4658,9 +5027,7 @@
</div>`;
panel.innerHTML = html;
requestAnimationFrame(() => {
panel.querySelectorAll('.sb-template-card video').forEach(v => v.play().catch(()=>{}));
});
// Cards show static poster frames; videos mount only on hover (_tplPlay).
// Check FaceFusion availability
fetch(`${API}/faceswap/check`).then(r => r.json()).then(s => {
const hairCb = document.getElementById('sbFsHair');
@@ -4863,15 +5230,8 @@
if (availableVideos.length) {
html += '<div class="sb-template-grid">';
availableVideos.forEach(v => {
const stem = v.replace(/\.[^.]+$/, '');
const vSafe = v.replace(/'/g, "\\'");
const sel = _sceneVideo === v ? ' selected' : '';
html += `<div class="sb-template-card${sel}" onclick="sceneSelectVideo('${vSafe}')"
onmouseenter="const v=this.querySelector('video');v&&v.play().catch(()=>{})" onmouseleave="const v=this.querySelector('video');v&&v.pause()">
<video src="${API}/wireframe/${encodeURIComponent(v)}" muted loop playsinline
preload="metadata" style="pointer-events:none;width:100%;height:100%;object-fit:cover"></video>
<div class="sb-template-label">${escHtml(stem)}</div>
</div>`;
html += _tplCardHTML(v, _sceneVideo === v, `sceneSelectVideo('${vSafe}')`);
});
html += '</div>';
}
@@ -4913,9 +5273,7 @@
if (scrubber) { scrubber.max = _sceneDuration; scrubber.step = Math.max(0.05, _sceneDuration / 500); }
}
}
requestAnimationFrame(() => {
panel.querySelectorAll('.sb-template-card video').forEach(v => v.play().catch(()=>{}));
});
// Cards show static poster frames; videos mount only on hover (_tplPlay).
}
async function sceneSelectVideo(v) {

View File

@@ -1,13 +1,13 @@
{
"api_url": "http://127.0.0.1:8500/edit",
"prompt": "high quality. realistic. detailed, female nude. realistic, high quality. realistic. detailed. female nude. realistic",
"prompt": "high quality. hyper realistic. detailed, detailed skin, detailed female nude. realistic, high quality. realistic. detailed. female nude. realistic",
"base_prompts": [
"Head-on full-nude-body three-quarter female portrait, realistic, transparent background",
"Head-on straight-on full-nude-body female portrait, realistic, transparent background",
"Head-on straight-on full-body female portrait, realistic, no background",
"Head-on full-nude-body three-quarter female portrait, realistic, black void background",
"Head-on straight-on full-nude-body female portrait, realistic, black void background",
"Head-on straight-on full-body female portrait, realistic, black void background",
"high quality, full-nude-body, female, masterpiece, realistic, photo, looking at viewer",
"high quality, full-nude-body, female, masterpiece, realistic, photo, detailed skin, professional lighting, transparent background",
"high quality, full-nude-body, female, masterpiece, realistic, photo, cinematic lighting, dramatic shadows, sharp focus, transparent background"
"high quality, full-nude-body, female, masterpiece, realistic, photo, detailed skin, professional lighting, black void background",
"high quality, full-nude-body, female, masterpiece, realistic, photo, cinematic lighting, dramatic shadows, sharp focus, black void background"
],
"seed": -1,
"max_area": 655360,

View File

@@ -993,6 +993,8 @@ def _move_to_trash(filepath: str):
# --- static data files -------------------------------------------------------
_static_write_lock = threading.Lock()
_invalidate_timer: "threading.Timer | None" = None
_invalidate_timer_lock = threading.Lock()
def _write_json(path: str, data) -> None:
@@ -1084,8 +1086,17 @@ def _write_all_static() -> None:
def _invalidate_static() -> None:
"""Spawn a daemon thread to regenerate all static data files (non-blocking)."""
threading.Thread(target=_write_all_static, daemon=True).start()
"""Coalesce rapid invalidation calls — restarts a 0.3 s debounce timer each time.
At most one _write_all_static() runs per quiet window, preventing thread floods
during batch jobs that call this after every single image."""
global _invalidate_timer
with _invalidate_timer_lock:
if _invalidate_timer is not None:
_invalidate_timer.cancel()
t = threading.Timer(0.3, _write_all_static)
t.daemon = True
t.start()
_invalidate_timer = t
# -----------------------------------------------------------------------------
@@ -1253,6 +1264,9 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
print(f"DB error in multi-ref: {db_err}")
jobs[job_id]["done"] += 1
# Regenerate static JSON so the frontend's polling picks up the new
# image immediately (progressive refresh, matching _batch_worker).
_invalidate_static()
except Exception as e:
print(f"Error in multi-ref for prompt '{prompt}': {e}")
jobs[job_id]["failed"] += 1
@@ -1734,7 +1748,9 @@ def merge_groups(req: MergeRequest):
except Exception as db_err:
print(f"Database error in merge: {db_err}")
_invalidate_static()
# Write synchronously: the frontend reloads images.json immediately after this
# returns, so an async rebuild would race and show the pre-merge grouping.
_write_all_static()
return {"group_id": gid, "files": req.filenames}
@@ -1750,7 +1766,7 @@ def extract_from_group(req: ExtractRequest):
except Exception as db_err:
print(f"Database error in extract: {db_err}")
_invalidate_static()
_write_all_static()
return {"filename": req.filename}
@@ -1928,6 +1944,10 @@ def _process_upload(file_path: str, filename: str, prompts: list[str], name: str
clip_description=clip_desc, tags=tags, embedding=embedding,
group_id=group_id, sort_order=0, has_clothing=has_clothing,
)
# Surface the new group with its base image right away — the pose/base-prompt
# generation below can take a while, and the user shouldn't wait for it to
# see the group land on the gallery.
_invalidate_static()
# 4. Crop if needed
cropped_pil = _crop_to_bbox(pil)
@@ -2162,16 +2182,38 @@ def remove_background(filename: str):
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
path = person[5]
with open(path, "rb") as f:
png_bytes = f.read()
transparent_png = _apply_transparency(png_bytes)
with open(path, "wb") as f:
f.write(transparent_png)
# Persist the state + refresh static data so the flag (and No-BG/Crop buttons)
# survive a page reload instead of reverting to has_background=True.
database.upsert_person(filename, has_background=False)
_invalidate_static()
return {"status": "success", "filename": filename, "has_background": False}
@app.post("/images/{filename}/invert-alpha")
def invert_alpha(filename: str):
"""Invert the alpha channel in place — recovers cases where background removal
kept the background and dropped the subject (the wrong segment)."""
import numpy as np
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
path = person[5]
img = Image.open(path).convert("RGBA")
arr = np.array(img)
arr[:, :, 3] = 255 - arr[:, :, 3]
Image.fromarray(arr, "RGBA").save(path, format="PNG")
database.upsert_person(filename, has_background=False)
_invalidate_static()
return {"status": "success", "filename": filename}
@@ -2747,6 +2789,358 @@ def sam2_check():
return {"sam2": predictor is not False and predictor is not None}
# --- 2D body-pose preview -----------------------------------------------------
# Estimates COCO-17 keypoints from the model image so the UI can overlay a
# posenet-style skeleton. Estimator is feature-detected: rtmlib (ONNX, reuses the
# already-installed onnxruntime) is preferred, mediapipe is a fallback. If neither
# is installed the endpoints report unavailable instead of erroring the request.
_pose_estimator = None # cached (callable, backend_name) or False if unavailable
_pose_lock = threading.Lock()
# COCO-17 keypoint names (the order rtmlib's Body model returns).
POSE_KEYPOINT_NAMES = [
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
"left_shoulder", "right_shoulder", "left_elbow", "right_elbow",
"left_wrist", "right_wrist", "left_hip", "right_hip",
"left_knee", "right_knee", "left_ankle", "right_ankle",
]
# Bone connections (index pairs into COCO-17) for drawing the skeleton.
POSE_SKELETON = [
(5, 7), (7, 9), (6, 8), (8, 10), # arms
(11, 13), (13, 15), (12, 14), (14, 16), # legs
(5, 6), (11, 12), (5, 11), (6, 12), # torso
(0, 1), (0, 2), (1, 3), (2, 4), (0, 5), (0, 6), # head/neck
]
# mediapipe Pose (33 landmarks) → COCO-17 index map.
_MP_TO_COCO = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
def _load_pose_estimator():
global _pose_estimator
if _pose_estimator is not None:
return _pose_estimator
with _pose_lock:
if _pose_estimator is not None:
return _pose_estimator
# Preferred: rtmlib (RTMPose, ONNX) — returns COCO-17 directly.
try:
from rtmlib import Body
import numpy as np
model = Body(mode="balanced", backend="onnxruntime", device="cpu")
def _infer_rtm(pil):
bgr = np.array(pil.convert("RGB"))[:, :, ::-1]
kpts, scores = model(bgr) # (N,17,2), (N,17)
people = []
for person_kpts, person_scores in zip(kpts, scores):
people.append([[float(x), float(y), float(s)]
for (x, y), s in zip(person_kpts, person_scores)])
return people
_pose_estimator = (_infer_rtm, "rtmlib")
print("[pose] using rtmlib (RTMPose)")
return _pose_estimator
except Exception as e:
print(f"[pose] rtmlib unavailable: {e}")
# Fallback: mediapipe Pose (single person, normalized landmarks).
try:
import mediapipe as mp
import numpy as np
mp_pose = mp.solutions.pose.Pose(static_image_mode=True, model_complexity=2)
def _infer_mp(pil):
rgb = np.array(pil.convert("RGB"))
h, w = rgb.shape[:2]
res = mp_pose.process(rgb)
if not res.pose_landmarks:
return []
lm = res.pose_landmarks.landmark
kpts = []
for mp_idx in _MP_TO_COCO:
p = lm[mp_idx]
kpts.append([float(p.x * w), float(p.y * h), float(p.visibility)])
return [kpts]
_pose_estimator = (_infer_mp, "mediapipe")
print("[pose] using mediapipe Pose")
return _pose_estimator
except Exception as e:
print(f"[pose] mediapipe unavailable: {e}")
_pose_estimator = False
return _pose_estimator
# --- pose similarity (descriptor + index) -------------------------------------
# Pose descriptors are normalized (translation + scale invariant) COCO-17 vectors,
# cached in <output>/_data/poses_index.json so we can rank library images by pose.
_POSE_MIN_SCORE = 0.3
# Left/right keypoint pairs for the mirror-invariant distance.
_POSE_MIRROR = [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15]
_pose_index_status = {"running": False, "done": 0, "total": 0}
def _pose_descriptor(keypoints):
"""Normalize one person's COCO-17 keypoints into a translation/scale-invariant
descriptor: {"vec": [34 floats], "vis": [17 ints]}. Returns None if too sparse."""
vis = [1 if (kp[2] >= _POSE_MIN_SCORE) else 0 for kp in keypoints]
if sum(vis) < 6:
return None
def _mid(a, b):
if vis[a] and vis[b]:
return ((keypoints[a][0] + keypoints[b][0]) / 2.0,
(keypoints[a][1] + keypoints[b][1]) / 2.0)
return None
hip = _mid(11, 12)
sho = _mid(5, 6)
center = hip or sho
if center is None:
return None
# Scale by torso length; fall back to keypoint spread if torso isn't visible.
if hip and sho:
scale = ((hip[0] - sho[0]) ** 2 + (hip[1] - sho[1]) ** 2) ** 0.5
else:
xs = [keypoints[i][0] for i in range(17) if vis[i]]
ys = [keypoints[i][1] for i in range(17) if vis[i]]
scale = max(max(xs) - min(xs), max(ys) - min(ys))
if not scale or scale < 1e-3:
return None
vec = []
for i in range(17):
if vis[i]:
vec.append((keypoints[i][0] - center[0]) / scale)
vec.append((keypoints[i][1] - center[1]) / scale)
else:
vec.extend([0.0, 0.0])
return {"vec": vec, "vis": vis}
def _pose_distance(a, b):
"""Weighted L2 between two descriptors over jointly-visible joints, taking the
min of the direct and left-right-mirrored comparison. Lower = more similar."""
def _dist(av, avis, bv, bvis, mirror):
total, n = 0.0, 0
for i in range(17):
j = _POSE_MIRROR[i] if mirror else i
if not (avis[i] and bvis[j]):
continue
bx = bv[j * 2] * (-1 if mirror else 1) # flip x when mirrored
by = bv[j * 2 + 1]
dx = av[i * 2] - bx
dy = av[i * 2 + 1] - by
total += dx * dx + dy * dy
n += 1
return (total / n) ** 0.5 if n >= 4 else float("inf")
direct = _dist(a["vec"], a["vis"], b["vec"], b["vis"], False)
mirror = _dist(a["vec"], a["vis"], b["vec"], b["vis"], True)
return min(direct, mirror)
def _best_person(people):
"""Pick the largest-bbox person from an estimator result (most prominent subject)."""
best, best_area = None, -1.0
for kpts in people:
xs = [k[0] for k in kpts if k[2] >= _POSE_MIN_SCORE]
ys = [k[1] for k in kpts if k[2] >= _POSE_MIN_SCORE]
if len(xs) < 2:
continue
area = (max(xs) - min(xs)) * (max(ys) - min(ys))
if area > best_area:
best, best_area = kpts, area
return best
def _pose_index_path():
return os.path.join(_load_output_dir(), "_data", "poses_index.json")
def _load_pose_index():
try:
with open(_pose_index_path(), "r") as f:
return json.load(f)
except Exception:
return {}
_pose_index_lock = threading.Lock()
def _save_pose_index_entry(filename, desc):
with _pose_index_lock:
idx = _load_pose_index()
idx[filename] = desc
os.makedirs(os.path.dirname(_pose_index_path()), exist_ok=True)
_write_json(_pose_index_path(), idx)
@app.get("/pose/check")
def pose_check():
"""Report whether a body-pose estimator is available (and which backend)."""
est = _load_pose_estimator()
if not est:
return {"available": False,
"hint": "pip install rtmlib onnxruntime (or: pip install mediapipe)"}
return {"available": True, "backend": est[1]}
@app.post("/images/{filename}/pose")
def estimate_pose(filename: str):
"""Estimate COCO-17 body keypoints for an image. Returns pixel-space keypoints
plus the skeleton edge list so the frontend can overlay a posenet-style preview."""
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
est = _load_pose_estimator()
if not est:
raise HTTPException(501, "No pose estimator installed. Try: pip install rtmlib onnxruntime")
infer, backend = est
pil = Image.open(person[5]).convert("RGB")
try:
people = infer(pil)
except Exception as e:
raise HTTPException(500, f"Pose estimation failed: {e}")
# Cache the descriptor so "find similar pose" can rank this image later.
best = _best_person(people)
if best is not None:
desc = _pose_descriptor(best)
if desc is not None:
try:
_save_pose_index_entry(filename, desc)
except Exception as e:
print(f"[pose] index save failed for {filename}: {e}")
return {
"status": "success",
"backend": backend,
"width": pil.width,
"height": pil.height,
"names": POSE_KEYPOINT_NAMES,
"skeleton": POSE_SKELETON,
"people": people,
}
def _build_pose_index_task():
try:
est = _load_pose_estimator()
if not est:
return
infer, _ = est
output_dir = _load_output_dir()
with _pose_index_lock:
idx = _load_pose_index()
persons = database.list_persons()
todo = [p[0] for p in persons
if p[0] not in idx
and (p[12] or "image") != "video"
and p[0].lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))]
_pose_index_status.update(running=True, done=0, total=len(todo))
print(f"[pose] index build: {len(todo)} images to process")
dirty = 0
for fn in todo:
try:
fpath = os.path.join(output_dir, fn)
if os.path.exists(fpath):
best = _best_person(infer(Image.open(fpath).convert("RGB")))
desc = _pose_descriptor(best) if best is not None else None
if desc is not None:
idx[fn] = desc
dirty += 1
except Exception as e:
print(f"[pose] index error for {fn}: {e}")
_pose_index_status["done"] += 1
# Batch-flush every 50 to avoid O(n^2) full-file rewrites.
if dirty >= 50:
with _pose_index_lock:
_write_json(_pose_index_path(), idx)
dirty = 0
print(f"[pose] index progress: {_pose_index_status['done']}/{len(todo)}")
with _pose_index_lock:
_write_json(_pose_index_path(), idx)
print(f"[pose] index build complete: {len(idx)} entries")
except Exception as e:
print(f"[pose] index build failed: {e}")
finally:
_pose_index_status["running"] = False
@app.post("/pose/index")
def build_pose_index():
"""Compute pose descriptors for all library images lacking one (daemon thread)."""
if not _load_pose_estimator():
raise HTTPException(501, "No pose estimator installed. Try: pip install rtmlib onnxruntime")
if _pose_index_status.get("running"):
return {"status": "already_running", **_pose_index_status}
threading.Thread(target=_build_pose_index_task, daemon=True).start()
return {"status": "started"}
@app.get("/pose/index/status")
def pose_index_status():
idx = _load_pose_index()
return {**_pose_index_status, "indexed": len(idx)}
def _rank_similar_poses(query_desc, limit, exclude=None):
idx = _load_pose_index()
scored = []
for fn, desc in idx.items():
if fn == exclude or not desc or "vec" not in desc:
continue
d = _pose_distance(query_desc, desc)
if d != float("inf"):
scored.append((d, fn))
scored.sort(key=lambda x: x[0])
groups = get_groups() if scored else {}
return [{"filename": fn, "group_id": groups.get(fn), "distance": round(d, 4)}
for d, fn in scored[:limit]]
@app.get("/pose/similar/{filename}")
def similar_pose(filename: str, limit: int = 12):
"""Rank library images by pose similarity to the given image."""
idx = _load_pose_index()
query = idx.get(filename)
if query is None:
# Compute on demand (also caches it).
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
est = _load_pose_estimator()
if not est:
raise HTTPException(501, "No pose estimator installed.")
best = _best_person(est[0](Image.open(person[5]).convert("RGB")))
query = _pose_descriptor(best) if best is not None else None
if query is None:
raise HTTPException(404, "No detectable pose in this image")
try:
_save_pose_index_entry(filename, query)
except Exception:
pass
return {"filename": filename, "similar": _rank_similar_poses(query, limit, exclude=filename)}
class PoseSimilarRequest(BaseModel):
keypoints: list[list[float]] # [[x,y,score], ...17] in image pixels
width: int = 0
height: int = 0
limit: int = 12
@app.post("/pose/similar")
def similar_pose_from_keypoints(req: PoseSimilarRequest):
"""Rank library images by similarity to a supplied (e.g. hand-edited) skeleton."""
query = _pose_descriptor(req.keypoints)
if query is None:
raise HTTPException(400, "Supplied pose is too sparse to match")
return {"similar": _rank_similar_poses(query, req.limit)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
[Unit]
Description=Qwen-Image-Edit FastAPI Service
After=comfyui-backend.service
Requires=comfyui-backend.service
[Service]
Type=simple
User=__USER__
Group=__GROUP__
WorkingDirectory=__BASE__/api
ExecStart=/bin/bash __BASE__/api/start_api.sh
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -1,17 +0,0 @@
[Unit]
Description=ComfyUI Backend for Qwen-Image-Edit
After=network.target
[Service]
Type=simple
User=__USER__
Group=__GROUP__
WorkingDirectory=__BASE__
ExecStart=/bin/bash __BASE__/api/run_comfyui.sh
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target