426 lines
17 KiB
HTML
426 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Background Removal & Segmentation</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||
background: #15171c; color: #e8e8ea; padding: 24px;
|
||
}
|
||
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 4px; }
|
||
.sub { color: #8a8f99; font-size: .85rem; margin-bottom: 16px; }
|
||
.wrap { max-width: 1100px; margin: 0 auto; }
|
||
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid #2a2f3a; }
|
||
.tab { background: none; border: 0; color: #8a8f99; font-size: .9rem; font-weight: 600;
|
||
padding: 10px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
||
.tab.active { color: #e8e8ea; border-bottom-color: #5b8cff; }
|
||
|
||
#drop {
|
||
border: 2px dashed #3a3f4b; border-radius: 12px; padding: 36px;
|
||
text-align: center; cursor: pointer; transition: border-color .15s, background .15s;
|
||
}
|
||
#drop.over { border-color: #5b8cff; background: #1c2230; }
|
||
#drop p { margin: 6px 0; color: #8a8f99; }
|
||
|
||
.controls { display: flex; gap: 12px; align-items: flex-end; margin: 14px 0; flex-wrap: wrap; }
|
||
.controls[hidden] { display: none; }
|
||
label.field { display: flex; flex-direction: column; gap: 4px; font-size: .72rem;
|
||
color: #8a8f99; text-transform: uppercase; letter-spacing: .04em; }
|
||
select, input[type=number], input[type=text] {
|
||
background: #2a2f3a; color: #e8e8ea; border: 1px solid #3a3f4b;
|
||
border-radius: 8px; padding: 8px 10px; font-size: .9rem;
|
||
}
|
||
input[type=number] { width: 78px; }
|
||
input[type=number]:disabled { opacity: .45; }
|
||
input[type=text]#prompt { width: 320px; }
|
||
.check { display: flex; align-items: center; gap: 6px; font-size: .85rem;
|
||
color: #e8e8ea; cursor: pointer; align-self: end; padding-bottom: 8px; }
|
||
.check input { width: 15px; height: 15px; accent-color: #5b8cff; cursor: pointer; }
|
||
|
||
/* help tooltips */
|
||
.help { display: inline-flex; align-items: center; justify-content: center;
|
||
width: 14px; height: 14px; margin-left: 5px; border-radius: 50%;
|
||
border: 1px solid #4a4f5b; color: #8a8f99; font-size: 9px; font-weight: 700;
|
||
font-style: normal; cursor: help; position: relative; vertical-align: middle; }
|
||
.help:hover { color: #e8e8ea; border-color: #5b8cff; }
|
||
.help:hover::after {
|
||
content: attr(data-tip); position: absolute; bottom: 150%; left: 50%;
|
||
transform: translateX(-50%); width: 220px; background: #0c0d11;
|
||
color: #d8d9dc; border: 1px solid #3a3f4b; border-radius: 6px;
|
||
padding: 7px 9px; font-size: .72rem; font-weight: 400; line-height: 1.4;
|
||
text-transform: none; letter-spacing: normal; white-space: normal;
|
||
z-index: 50; pointer-events: none; }
|
||
|
||
button.go {
|
||
background: #5b8cff; color: #fff; border: 0; border-radius: 8px;
|
||
padding: 10px 18px; font-size: .9rem; cursor: pointer; font-weight: 600;
|
||
}
|
||
button.go:disabled { background: #3a3f4b; cursor: not-allowed; }
|
||
button.ghost { background: #2a2f3a; color: #fff; border: 0; border-radius: 8px;
|
||
padding: 10px 18px; font-size: .9rem; cursor: pointer; font-weight: 600; }
|
||
.go-row { display: flex; gap: 12px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
|
||
.status { color: #8a8f99; font-size: .85rem; }
|
||
.status.err { color: #ff6b6b; }
|
||
.hint { color: #6b7280; font-size: .78rem; margin: -4px 0 4px; }
|
||
|
||
.panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
|
||
.panel { background: #1c1f27; border-radius: 12px; padding: 12px; }
|
||
.panel h2 { font-size: .8rem; font-weight: 600; color: #8a8f99; margin: 0 0 8px;
|
||
text-transform: uppercase; letter-spacing: .05em; }
|
||
.imgbox {
|
||
min-height: 260px; display: flex; align-items: center; justify-content: center;
|
||
border-radius: 8px; overflow: hidden;
|
||
}
|
||
.checker {
|
||
background-image:
|
||
linear-gradient(45deg, #2a2f3a 25%, transparent 25%),
|
||
linear-gradient(-45deg, #2a2f3a 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #2a2f3a 75%),
|
||
linear-gradient(-45deg, transparent 75%, #2a2f3a 75%);
|
||
background-size: 22px 22px;
|
||
background-position: 0 0, 0 11px, 11px -11px, -11px 0;
|
||
background-color: #20242d;
|
||
}
|
||
.imgbox img { max-width: 100%; max-height: 70vh; display: block; }
|
||
.imgbox img[src] { cursor: zoom-in; }
|
||
@media (max-width: 720px) { .panels { grid-template-columns: 1fr; } }
|
||
|
||
/* lightbox */
|
||
.lightbox { position: fixed; inset: 0; z-index: 100; background: rgba(12,13,17,.97);
|
||
display: flex; align-items: center; justify-content: center; }
|
||
.lightbox[hidden] { display: none; }
|
||
.lb-stage { width: 100vw; height: 100vh; overflow: hidden;
|
||
display: flex; align-items: center; justify-content: center; }
|
||
.lb-stage img { max-width: 100vw; max-height: 100vh; transform-origin: 0 0;
|
||
cursor: grab; user-select: none; -webkit-user-drag: none; will-change: transform; }
|
||
.lb-stage.grabbing img { cursor: grabbing; }
|
||
.lb-bar { position: fixed; top: 0; left: 0; right: 0; padding: 14px 20px;
|
||
z-index: 2; display: flex; justify-content: space-between; align-items: center;
|
||
color: #8a8f99; font-size: .8rem; pointer-events: none; }
|
||
.lb-close { pointer-events: auto; background: #2a2f3a; color: #e8e8ea;
|
||
border: 1px solid #3a3f4b; border-radius: 8px; width: 34px; height: 34px;
|
||
font-size: 1rem; line-height: 1; padding: 0; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>Background Removal & Segmentation</h1>
|
||
<div class="sub">Automatic removal, or prompt-conditioned segmentation.</div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="auto">Auto remove</button>
|
||
<button class="tab" data-tab="prompt">Prompt segment</button>
|
||
</div>
|
||
|
||
<div id="drop">
|
||
<p><strong>Drop an image here</strong> or click to choose</p>
|
||
<p id="fname">No file selected</p>
|
||
<input id="file" type="file" accept="image/*" hidden />
|
||
</div>
|
||
|
||
<!-- Auto (BiRefNet / RMBG-2.0) controls -->
|
||
<div class="controls" id="ctl-auto">
|
||
<label class="field">Model
|
||
<select id="model">
|
||
<option value="general">general — clean single subjects (fast)</option>
|
||
<option value="HR" selected>HR — large / detailed scenes</option>
|
||
<option value="portrait">portrait — people</option>
|
||
<option value="matting">matting — soft edges / hair</option>
|
||
<option value="lite">lite — fastest</option>
|
||
<option value="rmbg2">rmbg2 — BRIA RMBG-2.0</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">Resolution
|
||
<select id="resolution">
|
||
<option value="1024">1024</option>
|
||
<option value="1536">1536</option>
|
||
<option value="2048">2048</option>
|
||
<option value="2560" selected>2560</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Prompt (GroundingDINO + SAM) controls -->
|
||
<div class="controls" id="ctl-prompt" hidden>
|
||
<label class="field">Prompt — what to keep
|
||
<input type="text" id="prompt" placeholder="e.g. the dog · cow. person." />
|
||
</label>
|
||
<label class="field">
|
||
<span>Box threshold<span class="help" data-tip="Minimum confidence for GroundingDINO to keep a detected box. Lower finds more (and looser) objects; higher keeps only strong matches.">?</span></span>
|
||
<input type="number" id="boxThr" value="0.3" min="0" max="1" step="0.05" />
|
||
</label>
|
||
<label class="field">
|
||
<span>Text threshold<span class="help" data-tip="How strongly a detection must match your prompt words. Lower = looser word matching; higher = stricter.">?</span></span>
|
||
<input type="number" id="textThr" value="0.25" min="0" max="1" step="0.05" />
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Shared output controls -->
|
||
<div class="controls">
|
||
<label class="field">Background
|
||
<select id="background">
|
||
<option value="alpha" selected>transparent</option>
|
||
<option value="white">white</option>
|
||
<option value="black">black</option>
|
||
<option value="gray">gray</option>
|
||
<option value="green">green</option>
|
||
<option value="blue">blue</option>
|
||
<option value="red">red</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<span>Edge offset (px)<span class="help" data-tip="Grow (+) or shrink (−) the cutout edge by N pixels. A small negative value trims a leftover background-colored fringe around hair or fur.">?</span></span>
|
||
<input type="number" id="maskOffset" value="0" min="-20" max="20" step="1" />
|
||
</label>
|
||
<label class="field">
|
||
<span>Feather (px)<span class="help" data-tip="Gaussian blur applied to the mask edge, in pixels. Softens the cutout for smoother compositing onto a new background.">?</span></span>
|
||
<input type="number" id="maskBlur" value="0" min="0" max="64" step="1" />
|
||
</label>
|
||
<label class="check"><input type="checkbox" id="crop" checked /> Crop to subject</label>
|
||
<label class="field">Margin (in)
|
||
<input type="number" id="cropMargin" value="0" min="0" step="0.1" />
|
||
</label>
|
||
</div>
|
||
|
||
<div class="hint" id="hint"></div>
|
||
|
||
<div class="go-row">
|
||
<button class="go" id="go" disabled>Remove background</button>
|
||
<a id="dl" download="cutout.png"><button id="dlbtn" class="ghost" disabled>Download PNG</button></a>
|
||
<span id="status" class="status"></span>
|
||
</div>
|
||
|
||
<div class="panels">
|
||
<div class="panel">
|
||
<h2>Original</h2>
|
||
<div class="imgbox"><img id="src" alt="" /></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Result</h2>
|
||
<div class="imgbox checker"><img id="out" alt="" /></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="lightbox" class="lightbox" hidden>
|
||
<div class="lb-bar">
|
||
<span>scroll to zoom · drag to pan · double-click resets · Esc closes</span>
|
||
<button class="lb-close" id="lbClose" title="Close">✕</button>
|
||
</div>
|
||
<div class="lb-stage" id="lbStage"><img id="lbImg" alt="" /></div>
|
||
</div>
|
||
|
||
<script>
|
||
const drop = document.getElementById('drop');
|
||
const fileInput = document.getElementById('file');
|
||
const fname = document.getElementById('fname');
|
||
const go = document.getElementById('go');
|
||
const dl = document.getElementById('dl');
|
||
const dlbtn = document.getElementById('dlbtn');
|
||
const statusEl = document.getElementById('status');
|
||
const srcImg = document.getElementById('src');
|
||
const outImg = document.getElementById('out');
|
||
const hint = document.getElementById('hint');
|
||
|
||
const modelSel = document.getElementById('model');
|
||
const resSel = document.getElementById('resolution');
|
||
const promptInput = document.getElementById('prompt');
|
||
const boxThr = document.getElementById('boxThr');
|
||
const textThr = document.getElementById('textThr');
|
||
const bgSel = document.getElementById('background');
|
||
const maskOffset = document.getElementById('maskOffset');
|
||
const maskBlur = document.getElementById('maskBlur');
|
||
const cropChk = document.getElementById('crop');
|
||
const cropMargin = document.getElementById('cropMargin');
|
||
|
||
const ctlAuto = document.getElementById('ctl-auto');
|
||
const ctlPrompt = document.getElementById('ctl-prompt');
|
||
|
||
let selectedFile = null;
|
||
let tab = 'auto';
|
||
|
||
const HINTS = {
|
||
auto: 'Large or busy scenes segment best with HR at 2048+. The general model expects a clear single subject at 1024.',
|
||
prompt: 'Type what to keep, e.g. "the dog" (or several: "cow. person."). Lower the box threshold to detect more / fainter objects.',
|
||
};
|
||
|
||
function setTab(name) {
|
||
tab = name;
|
||
document.querySelectorAll('.tab').forEach(t =>
|
||
t.classList.toggle('active', t.dataset.tab === name));
|
||
ctlAuto.hidden = name !== 'auto';
|
||
ctlPrompt.hidden = name !== 'prompt';
|
||
go.textContent = name === 'auto' ? 'Remove background' : 'Segment';
|
||
hint.textContent = HINTS[name];
|
||
}
|
||
document.querySelectorAll('.tab').forEach(t =>
|
||
t.addEventListener('click', () => setTab(t.dataset.tab)));
|
||
cropChk.addEventListener('change', () => { cropMargin.disabled = !cropChk.checked; });
|
||
|
||
function setStatus(msg, isErr) {
|
||
statusEl.textContent = msg;
|
||
statusEl.className = 'status' + (isErr ? ' err' : '');
|
||
}
|
||
|
||
function pickFile(file) {
|
||
if (!file || !file.type.startsWith('image/')) {
|
||
setStatus('Please choose an image file.', true);
|
||
return;
|
||
}
|
||
selectedFile = file;
|
||
fname.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||
srcImg.src = URL.createObjectURL(file);
|
||
outImg.removeAttribute('src');
|
||
dlbtn.disabled = true;
|
||
go.disabled = false;
|
||
setStatus('');
|
||
}
|
||
|
||
drop.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', e => pickFile(e.target.files[0]));
|
||
['dragenter', 'dragover'].forEach(ev =>
|
||
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
|
||
['dragleave', 'drop'].forEach(ev =>
|
||
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
|
||
drop.addEventListener('drop', e => pickFile(e.dataTransfer.files[0]));
|
||
|
||
function fileToBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const r = new FileReader();
|
||
r.onload = () => resolve(r.result.split(',')[1]); // strip data URL prefix
|
||
r.onerror = reject;
|
||
r.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
// --- lightbox: click to inspect, scroll to zoom, drag to pan ---
|
||
const lightbox = document.getElementById('lightbox');
|
||
const lbStage = document.getElementById('lbStage');
|
||
const lbImg = document.getElementById('lbImg');
|
||
const lbClose = document.getElementById('lbClose');
|
||
let lbScale = 1, lbTx = 0, lbTy = 0, lbDrag = null;
|
||
|
||
function lbApply() {
|
||
lbImg.style.transform = `translate(${lbTx}px, ${lbTy}px) scale(${lbScale})`;
|
||
}
|
||
function lbReset() { lbScale = 1; lbTx = 0; lbTy = 0; lbApply(); }
|
||
|
||
function openLightbox(src, isResult) {
|
||
if (!src) return;
|
||
lbImg.src = src;
|
||
lbImg.classList.toggle('checker', !!isResult);
|
||
lbReset();
|
||
lightbox.hidden = false;
|
||
}
|
||
function closeLightbox() { lightbox.hidden = true; lbImg.removeAttribute('src'); }
|
||
|
||
srcImg.addEventListener('click', () => openLightbox(srcImg.getAttribute('src'), false));
|
||
outImg.addEventListener('click', () => openLightbox(outImg.getAttribute('src'), true));
|
||
lbClose.addEventListener('click', closeLightbox);
|
||
lightbox.addEventListener('mousedown', e => {
|
||
if (e.target === lightbox || e.target === lbStage) closeLightbox();
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && !lightbox.hidden) closeLightbox();
|
||
});
|
||
|
||
lbStage.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const rect = lbImg.getBoundingClientRect();
|
||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
|
||
const newScale = Math.min(8, Math.max(1, lbScale * factor));
|
||
const ratio = newScale / lbScale;
|
||
lbTx -= cx * (ratio - 1);
|
||
lbTy -= cy * (ratio - 1);
|
||
lbScale = newScale;
|
||
if (lbScale === 1) { lbTx = 0; lbTy = 0; }
|
||
lbApply();
|
||
}, { passive: false });
|
||
|
||
lbImg.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
lbDrag = { x: e.clientX, y: e.clientY, tx: lbTx, ty: lbTy };
|
||
lbStage.classList.add('grabbing');
|
||
});
|
||
window.addEventListener('mousemove', e => {
|
||
if (!lbDrag) return;
|
||
lbTx = lbDrag.tx + (e.clientX - lbDrag.x);
|
||
lbTy = lbDrag.ty + (e.clientY - lbDrag.y);
|
||
lbApply();
|
||
});
|
||
window.addEventListener('mouseup', () => {
|
||
lbDrag = null;
|
||
lbStage.classList.remove('grabbing');
|
||
});
|
||
lbImg.addEventListener('dblclick', e => { e.preventDefault(); lbReset(); });
|
||
|
||
go.addEventListener('click', async () => {
|
||
if (!selectedFile) return;
|
||
if (tab === 'prompt' && !promptInput.value.trim()) {
|
||
setStatus('Enter a prompt describing what to keep.', true);
|
||
return;
|
||
}
|
||
go.disabled = true;
|
||
dlbtn.disabled = true;
|
||
setStatus('Processing… (first use of a model downloads its weights)');
|
||
const t0 = performance.now();
|
||
try {
|
||
const b64 = await fileToBase64(selectedFile);
|
||
const shared = {
|
||
image: b64,
|
||
background: bgSel.value,
|
||
mask_offset: parseInt(maskOffset.value, 10) || 0,
|
||
mask_blur: parseInt(maskBlur.value, 10) || 0,
|
||
crop: cropChk.checked,
|
||
crop_margin: parseFloat(cropMargin.value) || 0,
|
||
};
|
||
let endpoint, body;
|
||
if (tab === 'auto') {
|
||
endpoint = '/predict';
|
||
body = { ...shared, model: modelSel.value, resolution: parseInt(resSel.value, 10) };
|
||
} else {
|
||
endpoint = '/segment';
|
||
body = { ...shared, prompt: promptInput.value.trim(),
|
||
box_threshold: parseFloat(boxThr.value) || 0.3,
|
||
text_threshold: parseFloat(textThr.value) || 0.25 };
|
||
}
|
||
const resp = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + (await resp.text()));
|
||
const data = await resp.json();
|
||
const dataUrl = 'data:image/png;base64,' + data.image;
|
||
outImg.src = dataUrl;
|
||
dl.href = dataUrl;
|
||
dl.download = selectedFile.name.replace(/\.[^.]+$/, '') + '.png';
|
||
dlbtn.disabled = false;
|
||
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
||
if (tab === 'auto') {
|
||
setStatus(`Done — ${data.width}×${data.height} · ${data.model} @ ${data.resolution} · ${secs}s`);
|
||
} else {
|
||
const n = data.detections;
|
||
setStatus(`Done — ${n} object${n === 1 ? '' : 's'} matched "${data.prompt}" · ` +
|
||
`${data.width}×${data.height} · ${secs}s` +
|
||
(n === 0 ? ' (try a lower box threshold)' : ''));
|
||
}
|
||
} catch (err) {
|
||
setStatus(err.message || String(err), true);
|
||
} finally {
|
||
go.disabled = false;
|
||
}
|
||
});
|
||
|
||
setTab('auto');
|
||
</script>
|
||
</body>
|
||
</html>
|