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:
mike
2026-06-23 21:52:59 +02:00
parent 3d44c7aba4
commit 188682b4c3
6 changed files with 446 additions and 81 deletions

View File

@@ -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 01
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(' &nbsp; ');
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;