|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Kemono Friends Sorter</title> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<style> |
|
|
body { font-family: 'Segoe UI', sans-serif; background-color: #f3f4f6; } |
|
|
.container { max-width: 900px; margin: 0 auto; padding: 20px; } |
|
|
|
|
|
|
|
|
.results-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); |
|
|
gap: 1.5rem; |
|
|
} |
|
|
|
|
|
.card { |
|
|
background: white; |
|
|
border-radius: 0.75rem; |
|
|
padding: 1rem; |
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); |
|
|
transition: transform 0.2s; |
|
|
} |
|
|
.card:hover { transform: translateY(-2px); } |
|
|
|
|
|
.card-img { |
|
|
width: 100%; |
|
|
height: 200px; |
|
|
object-fit: cover; |
|
|
border-radius: 0.5rem; |
|
|
margin-bottom: 1rem; |
|
|
background-color: #eee; |
|
|
} |
|
|
|
|
|
.bar-bg { background-color: #e5e7eb; height: 8px; border-radius: 4px; width: 100%; overflow: hidden; margin-top: 2px;} |
|
|
.bar-fill { height: 100%; transition: width 0.3s ease; } |
|
|
|
|
|
|
|
|
.label-KF { background-color: #16a34a; } |
|
|
.label-NonKF { background-color: #f59e0b; } |
|
|
.label-Rejected { background-color: #dc2626; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
|
|
|
<div class="container"> |
|
|
<div class="bg-white p-6 rounded-xl shadow-lg mb-8"> |
|
|
<h1 class="text-3xl font-bold mb-2 text-gray-800">Kemono Friends Sorter</h1> |
|
|
<p class="text-sm text-gray-500 mb-4">Batch processing with WebGPU. All data stays in your browser. Sorts images between KF, NonKF, and Rejected, and allows you to download the results.</p> |
|
|
|
|
|
|
|
|
<div class="space-y-4"> |
|
|
|
|
|
<div id="status" class="text-sm font-semibold text-blue-600 animate-pulse"> |
|
|
Loading model... |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex flex-col md:flex-row gap-4 items-center"> |
|
|
<input type="file" id="file-upload" accept="image/*" multiple disabled |
|
|
class="block w-full text-sm text-slate-500 |
|
|
file:mr-4 file:py-2 file:px-4 |
|
|
file:rounded-full file:border-0 |
|
|
file:text-sm file:font-semibold |
|
|
file:bg-orange-50 file:text-orange-700 |
|
|
hover:file:bg-orange-100 |
|
|
cursor-pointer disabled:opacity-50" |
|
|
/> |
|
|
|
|
|
<button id="run-btn" disabled |
|
|
class="px-6 py-2 bg-blue-600 text-white font-semibold rounded-full shadow hover:bg-blue-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed"> |
|
|
Run Inference |
|
|
</button> |
|
|
|
|
|
<button id="download-btn" disabled style="display:none;" |
|
|
class="px-6 py-2 bg-green-600 text-white font-semibold rounded-full shadow hover:bg-green-700 transition flex items-center gap-2"> |
|
|
<span>⬇️</span> Download Results |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div id="file-count" class="text-sm text-gray-500 hidden">0 files selected</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="results-area" class="results-grid"> |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script type="module"> |
|
|
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers'; |
|
|
|
|
|
const MODEL_ID = 'HAV0X1014/ONNX-KF-Sorter'; |
|
|
|
|
|
|
|
|
const statusEl = document.getElementById('status'); |
|
|
const fileInput = document.getElementById('file-upload'); |
|
|
const runBtn = document.getElementById('run-btn'); |
|
|
const downloadBtn = document.getElementById('download-btn'); |
|
|
const resultsArea = document.getElementById('results-area'); |
|
|
const fileCountEl = document.getElementById('file-count'); |
|
|
|
|
|
let classifier = null; |
|
|
let selectedFiles = []; |
|
|
let processingResults = []; |
|
|
|
|
|
|
|
|
async function init() { |
|
|
try { |
|
|
|
|
|
classifier = await pipeline('image-classification', MODEL_ID, { device: "webgpu" },); |
|
|
|
|
|
statusEl.textContent = 'Model Loaded'; |
|
|
statusEl.classList.remove('text-blue-600', 'animate-pulse'); |
|
|
statusEl.classList.add('text-green-600'); |
|
|
fileInput.disabled = false; |
|
|
} catch (err) { |
|
|
statusEl.textContent = 'Error loading model. Check console.'; |
|
|
console.error(err); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
fileInput.addEventListener('change', (e) => { |
|
|
selectedFiles = Array.from(e.target.files); |
|
|
if (selectedFiles.length > 0) { |
|
|
fileCountEl.textContent = `${selectedFiles.length} file(s) selected`; |
|
|
fileCountEl.classList.remove('hidden'); |
|
|
runBtn.disabled = false; |
|
|
} else { |
|
|
fileCountEl.classList.add('hidden'); |
|
|
runBtn.disabled = true; |
|
|
} |
|
|
|
|
|
downloadBtn.style.display = 'none'; |
|
|
resultsArea.innerHTML = ''; |
|
|
processingResults = []; |
|
|
}); |
|
|
|
|
|
|
|
|
runBtn.addEventListener('click', async () => { |
|
|
if (!classifier || selectedFiles.length === 0) return; |
|
|
|
|
|
runBtn.disabled = true; |
|
|
fileInput.disabled = true; |
|
|
resultsArea.innerHTML = ''; |
|
|
processingResults = []; |
|
|
|
|
|
let processedCount = 0; |
|
|
|
|
|
|
|
|
for (const file of selectedFiles) { |
|
|
statusEl.textContent = `Processing ${processedCount + 1}/${selectedFiles.length}: ${file.name}`; |
|
|
|
|
|
try { |
|
|
|
|
|
const imageUrl = await readFileAsDataURL(file); |
|
|
|
|
|
|
|
|
const output = await classifier(imageUrl, { topk: 3 }); |
|
|
|
|
|
|
|
|
createResultCard(file.name, imageUrl, output); |
|
|
|
|
|
|
|
|
const topResult = output[0]; |
|
|
processingResults.push(`${file.name}|${topResult.label}`); |
|
|
|
|
|
} catch (err) { |
|
|
console.error(`Error processing ${file.name}`, err); |
|
|
|
|
|
const errDiv = document.createElement('div'); |
|
|
errDiv.className = 'card text-red-500'; |
|
|
errDiv.innerText = `Error processing ${file.name}`; |
|
|
resultsArea.appendChild(errDiv); |
|
|
} |
|
|
|
|
|
processedCount++; |
|
|
} |
|
|
|
|
|
statusEl.textContent = 'Processing Complete'; |
|
|
runBtn.disabled = false; |
|
|
fileInput.disabled = false; |
|
|
downloadBtn.style.display = 'flex'; |
|
|
downloadBtn.disabled = false; |
|
|
}); |
|
|
|
|
|
|
|
|
function createResultCard(filename, imgUrl, predictions) { |
|
|
|
|
|
const topLabel = predictions[0].label; |
|
|
|
|
|
let statsHtml = ''; |
|
|
predictions.forEach(p => { |
|
|
const percent = (p.score * 100).toFixed(1); |
|
|
|
|
|
const colorClass = `label-${p.label}`; |
|
|
|
|
|
statsHtml += ` |
|
|
<div class="mb-2"> |
|
|
<div class="flex justify-between text-xs font-semibold text-gray-700"> |
|
|
<span>${p.label}</span> |
|
|
<span>${percent}%</span> |
|
|
</div> |
|
|
<div class="bar-bg"> |
|
|
<div class="bar-fill ${colorClass}" style="width: ${percent}%;"></div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}); |
|
|
|
|
|
const card = document.createElement('div'); |
|
|
card.className = 'card fade-in'; |
|
|
card.innerHTML = ` |
|
|
<img src="${imgUrl}" class="card-img" alt="${filename}"> |
|
|
<div class="text-xs text-gray-400 mb-2 truncate" title="${filename}">${filename}</div> |
|
|
<div class="font-bold text-lg mb-2 text-gray-800 border-b pb-1">${topLabel}</div> |
|
|
<div>${statsHtml}</div> |
|
|
`; |
|
|
resultsArea.appendChild(card); |
|
|
} |
|
|
|
|
|
|
|
|
downloadBtn.addEventListener('click', () => { |
|
|
if (processingResults.length === 0) return; |
|
|
|
|
|
const content = processingResults.join('\n'); |
|
|
const blob = new Blob([content], { type: 'text/plain' }); |
|
|
const url = URL.createObjectURL(blob); |
|
|
|
|
|
const a = document.createElement('a'); |
|
|
a.href = url; |
|
|
a.download = 'results.txt'; |
|
|
document.body.appendChild(a); |
|
|
a.click(); |
|
|
document.body.removeChild(a); |
|
|
URL.revokeObjectURL(url); |
|
|
}); |
|
|
|
|
|
|
|
|
function readFileAsDataURL(file) { |
|
|
return new Promise((resolve, reject) => { |
|
|
const reader = new FileReader(); |
|
|
reader.onload = () => resolve(reader.result); |
|
|
reader.onerror = reject; |
|
|
reader.readAsDataURL(file); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
init(); |
|
|
|
|
|
</script> |
|
|
</body> |
|
|
</html> |