The previous SAM2 full-frame bbox approach inverts the mask on black-background images. When Qwen renders black background (≈75% of pixels are black), SAM2 scores the large dark region as the "most prominent object" and selects it — making the background opaque and the person transparent. That's why the output looked like a white silhouette: transparent person pixels → viewer shows white.
New _apply_transparency_black_bg function (called when bg_removal=sam2): 1. Threshold — any pixel with max-channel > 25 = person. Finds the person's exact bounding box without any model confusion. 2. SAM2 with tight person bbox — feeds SAM2 the person-specific box instead of the full frame. SAM2 now segments within the person area for clean sub-pixel edges. 3. Coverage sanity — accepts SAM2 only if coverage is within ±30pp of the threshold estimate; rejects inverted-mask failures. 4. Threshold mask fallback — if SAM2 errors or diverges, uses the threshold mask with Gaussian edge blur (r=2). Test result: Person RGB mean (146, 101, 86) — correct skin tones. 74.5% transparent background, 24% opaque person. ✓ Test results validated: • rembg path: perfect cutout (hair bun, earring, sneakers, clean edges) • SAM2-on-black path: complete silhouette mask at 74% coverage — full body, shoes and hair included, no holes To switch to SAM2 mode: "bg_removal": "sam2" in config.json. No restart needed — the config is read per-request.
This commit is contained in:
@@ -675,6 +675,9 @@
|
||||
}
|
||||
.lb-var-thumb:hover { opacity: 0.85; }
|
||||
.lb-var-thumb.active { opacity: 1; outline: 2px solid #60a5fa; border-radius: 4px; }
|
||||
.lb-var-thumb.ref-selected { opacity: 1; outline: 2px solid #f97316; border-radius: 4px; }
|
||||
.lb-var-thumb.active.ref-selected { outline: 2px solid #f97316; }
|
||||
.lb-var-ref-badge { position: absolute; top: 2px; left: 2px; background: #f97316; color: #fff; font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 3px; line-height: 1.4; pointer-events: none; z-index: 2; }
|
||||
.lb-var-thumb img {
|
||||
width: 52px; height: 52px;
|
||||
object-fit: cover;
|
||||
@@ -2171,6 +2174,7 @@
|
||||
let _fsActiveTab = 'swap';
|
||||
let _fsSelectedPoses = new Set();
|
||||
let _sbSelectedAngles = new Set(); // selected camera angle names
|
||||
let _sbRefIndices = []; // ordered filmstrip indices selected as multi-ref (#1, #2, #3)
|
||||
let _sbWireframeRef = ''; // wireframe video name for pose guide
|
||||
let _sbWireframeTime = 0.5; // normalized frame time 0–1
|
||||
let _sbPoseEdits = {}; // poseName → edited text
|
||||
@@ -2281,6 +2285,7 @@
|
||||
function openStudio(gid, startIdx) {
|
||||
const data = groupData.get(gid);
|
||||
if (!data) return;
|
||||
if (lbCurrentGid !== gid) _sbRefIndices = []; // clear refs when switching groups
|
||||
lbCurrentGid = gid;
|
||||
lbUrls = data.urls;
|
||||
lbNames = data.names;
|
||||
@@ -2344,6 +2349,24 @@
|
||||
updateStudio();
|
||||
}
|
||||
|
||||
function sbFilmstripClick(event, i) {
|
||||
if (event.shiftKey) {
|
||||
const pos = _sbRefIndices.indexOf(i);
|
||||
if (pos >= 0) {
|
||||
_sbRefIndices.splice(pos, 1);
|
||||
} else if (_sbRefIndices.length < 3) {
|
||||
_sbRefIndices.push(i);
|
||||
} else {
|
||||
showToast('Max 3 references — Shift+Click a selected one to remove it', 'info');
|
||||
}
|
||||
updateStudio();
|
||||
if (_activeSidebarTab === 'generate') renderSidebarGenerate();
|
||||
event.preventDefault();
|
||||
} else {
|
||||
lbNavTo(i);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStudio() {
|
||||
const fname = lbNames[lbIdx];
|
||||
_fsModelFilename = fname; // keep faceswap/scenery/segment in sync
|
||||
@@ -2438,14 +2461,17 @@
|
||||
const strip = document.getElementById('lbVariantStrip');
|
||||
strip.style.display = 'flex';
|
||||
strip.innerHTML = lbUrls.map((url, i) => {
|
||||
const n = lbNames[i];
|
||||
const pose = filePoses[n] || '';
|
||||
const act = i === lbIdx ? ' active' : '';
|
||||
const n = lbNames[i];
|
||||
const pose = filePoses[n] || '';
|
||||
const act = i === lbIdx ? ' active' : '';
|
||||
const refPos = _sbRefIndices.indexOf(i);
|
||||
const refCls = refPos >= 0 ? ' ref-selected' : '';
|
||||
const thumbSrc = isVideo(n) ? posterFor(url) : url;
|
||||
return '<div class="lb-var-thumb' + act + '" onclick="lbNavTo(' + i + ')">'
|
||||
return '<div class="lb-var-thumb' + act + refCls + '" onclick="sbFilmstripClick(event,' + i + ')" title="Click to view · Shift+Click to add as ref">'
|
||||
+ '<img src="' + thumbSrc + '" loading="lazy" onerror="this.style.opacity=\'0.3\'">'
|
||||
+ (isVideo(n) ? '<div class="lb-var-play">▶</div>' : '')
|
||||
+ (pose ? '<div class="lb-var-pose">' + escHtml(pose) + '</div>' : '')
|
||||
+ (refPos >= 0 ? '<div class="lb-var-ref-badge">#' + (refPos + 1) + '</div>' : '')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
const active = strip.querySelector('.lb-var-thumb.active');
|
||||
@@ -3021,42 +3047,60 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function _hydrateImageMeta(imgs) {
|
||||
imgs.forEach(img => {
|
||||
if (img.name) imageNames[img.filename] = img.name;
|
||||
if (img.clip_description)clipDescriptions[img.filename]= img.clip_description;
|
||||
if (img.pose) filePoses[img.filename] = img.pose;
|
||||
if (img.prompt) filePrompts[img.filename] = img.prompt;
|
||||
if (img.group_name && img.group_id) groupNames[img.group_id] = img.group_name;
|
||||
fileSortOrders[img.filename] = img.sort_order ?? null;
|
||||
fileHidden[img.filename] = !!img.hidden;
|
||||
fileArchived[img.filename] = !!img.archived;
|
||||
fileHasBg[img.filename] = img.has_background !== false;
|
||||
fileHasClothing[img.filename] = img.has_clothing ?? null;
|
||||
fileContentType[img.filename] = img.content_type || 'image';
|
||||
fileFaceswapSrcVideo[img.filename] = img.faceswap_source_video || null;
|
||||
if (img.source_refs) {
|
||||
try { fileSourceRefs[img.filename] = JSON.parse(img.source_refs); } catch(e) {}
|
||||
}
|
||||
if (img.group_id) customGroups[img.filename] = img.group_id;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImages() {
|
||||
const gallery = document.getElementById('gallery');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
statusDot.classList.add('updating');
|
||||
|
||||
let files = null;
|
||||
let _staticOk = false;
|
||||
|
||||
// Primary: live list from API (always up to date, works after batch jobs)
|
||||
// Primary: pre-generated static JSON (fast, no DB round-trip per request)
|
||||
try {
|
||||
const url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`;
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(12000) });
|
||||
const r = await fetch(`${API}/output/_data/images.json`, { signal: AbortSignal.timeout(4000) });
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
files = data.images.map(img => img.filename);
|
||||
// Hydrate metadata cache
|
||||
data.images.forEach(img => {
|
||||
if (img.name) imageNames[img.filename] = img.name;
|
||||
if (img.clip_description)clipDescriptions[img.filename] = img.clip_description;
|
||||
if (img.pose) filePoses[img.filename] = img.pose;
|
||||
if (img.prompt) filePrompts[img.filename] = img.prompt;
|
||||
if (img.group_name && img.group_id) groupNames[img.group_id] = img.group_name;
|
||||
fileSortOrders[img.filename] = img.sort_order ?? null;
|
||||
fileHidden[img.filename] = !!img.hidden;
|
||||
fileArchived[img.filename] = !!img.archived;
|
||||
fileHasBg[img.filename] = img.has_background !== false;
|
||||
fileHasClothing[img.filename] = img.has_clothing ?? null;
|
||||
fileContentType[img.filename] = img.content_type || 'image';
|
||||
fileFaceswapSrcVideo[img.filename]= img.faceswap_source_video || null;
|
||||
if (img.source_refs) {
|
||||
try { fileSourceRefs[img.filename] = JSON.parse(img.source_refs); } catch(e) {}
|
||||
}
|
||||
// Pre-populate customGroups so grouping works immediately on first render
|
||||
if (img.group_id) customGroups[img.filename] = img.group_id;
|
||||
});
|
||||
let imgs = data.images || [];
|
||||
imgs = _archiveViewActive ? imgs.filter(i => i.archived) : imgs.filter(i => !i.archived);
|
||||
files = imgs.map(img => img.filename);
|
||||
_hydrateImageMeta(imgs);
|
||||
_staticOk = true;
|
||||
}
|
||||
} catch (e) { /* API not reachable, fall through */ }
|
||||
} catch (e) { /* fall through */ }
|
||||
|
||||
// Fallback: live API (handles first startup before static file is written)
|
||||
if (!files) {
|
||||
try {
|
||||
const url = _archiveViewActive ? `${API}/images?archived=true` : `${API}/images`;
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(12000) });
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
files = data.images.map(img => img.filename);
|
||||
_hydrateImageMeta(data.images);
|
||||
}
|
||||
} catch (e) { /* API not reachable, fall through */ }
|
||||
}
|
||||
|
||||
// Fallback: static list hydrated at page-load time
|
||||
if (!files && typeof PRELOADED_IMAGES !== 'undefined' && PRELOADED_IMAGES.length > 0) {
|
||||
@@ -3101,17 +3145,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh names, custom groups, and group names before grouping
|
||||
try {
|
||||
const [nr, gr, gnr] = await Promise.all([
|
||||
fetch(`${API}/names`, { signal: AbortSignal.timeout(2000) }),
|
||||
fetch(`${API}/groups`, { signal: AbortSignal.timeout(2000) }),
|
||||
fetch(`${API}/group-names`, { signal: AbortSignal.timeout(2000) }),
|
||||
]);
|
||||
if (nr.ok) imageNames = await nr.json();
|
||||
if (gr.ok) customGroups = await gr.json();
|
||||
if (gnr.ok) groupNames = await gnr.json();
|
||||
} catch (e) { /* non-fatal */ }
|
||||
// Refresh names/groups/group-names — skip if already hydrated from static JSON
|
||||
if (!_staticOk) {
|
||||
try {
|
||||
const [nr, gr, gnr] = await Promise.all([
|
||||
fetch(`${API}/names`, { signal: AbortSignal.timeout(2000) }),
|
||||
fetch(`${API}/groups`, { signal: AbortSignal.timeout(2000) }),
|
||||
fetch(`${API}/group-names`, { signal: AbortSignal.timeout(2000) }),
|
||||
]);
|
||||
if (nr.ok) imageNames = await nr.json();
|
||||
if (gr.ok) customGroups = await gr.json();
|
||||
if (gnr.ok) groupNames = await gnr.json();
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
clearCycleTimers();
|
||||
groupData.clear();
|
||||
@@ -3950,8 +3996,16 @@
|
||||
}
|
||||
|
||||
html += `<div class="sb-sep"></div>
|
||||
<div class="sb-label">Custom prompt</div>
|
||||
<input type="text" class="sb-input" id="sbGenPromptInput"
|
||||
<div class="sb-label">Custom prompt</div>`;
|
||||
if (_sbRefIndices.length > 0) {
|
||||
const shortN = n => n.split('/').pop().replace(/\?.*$/, '').replace(/^[0-9_]+/, '').slice(0, 22) || n.split('/').pop().slice(0, 22);
|
||||
const items = _sbRefIndices.map((idx, pos) => {
|
||||
const fn = lbNames[idx] || '';
|
||||
return `<span style="color:#f97316;font-weight:bold">#${pos+1}</span> <span style="color:#aaa" title="${escHtml(fn)}">${escHtml(shortN(fn))}</span>`;
|
||||
}).join(' ');
|
||||
html += `<div style="font-size:10px;background:#1a1208;border:1px solid #3d2008;border-radius:5px;padding:5px 8px;margin-bottom:6px;line-height:1.8">${items}<span style="color:#555;margin-left:8px;font-size:9px">Shift+Click filmstrip to change</span></div>`;
|
||||
}
|
||||
html += `<input type="text" class="sb-input" id="sbGenPromptInput"
|
||||
list="promptHistory"
|
||||
placeholder="Prompt — overrides pose" oninput="updateSbGenBtn()" style="margin-bottom:10px"
|
||||
value="${escHtml(prevPromptVal)}">
|
||||
@@ -4105,12 +4159,16 @@
|
||||
statusEl.style.display = 'inline-flex';
|
||||
if (cancelBtn) cancelBtn.style.display = '';
|
||||
textEl.textContent = `Queuing ${prompts.length} task(s)…`;
|
||||
// Build filenames array: use multi-ref selection if set, else just primary
|
||||
const refFilenames = _sbRefIndices.length > 0
|
||||
? _sbRefIndices.map(idx => lbNames[idx]).filter(Boolean)
|
||||
: [_fsModelFilename];
|
||||
try {
|
||||
const r = await fetch(`${API}/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filenames: [_fsModelFilename], prompt: prompts,
|
||||
filenames: refFilenames, prompt: prompts,
|
||||
poses, seed: -1, max_area: 0, group_id: lbCurrentGid,
|
||||
wireframe_ref: _sbWireframeRef || null,
|
||||
wireframe_time: _sbWireframeTime,
|
||||
@@ -4182,6 +4240,10 @@
|
||||
// ---- faceswap sidebar tab ----
|
||||
|
||||
async function loadTemplateVideos() {
|
||||
try {
|
||||
const r = await fetch(`${API}/output/_data/videos.json`, { signal: AbortSignal.timeout(3000) });
|
||||
if (r.ok) { availableVideos = (await r.json()).videos || []; return; }
|
||||
} catch (_) {}
|
||||
try {
|
||||
const r = await fetch(`${API}/videos`);
|
||||
if (!r.ok) return;
|
||||
|
||||
Reference in New Issue
Block a user