| <!DOCTYPE html> |
| <html lang="es"> |
| <head> |
| <meta charset="UTF-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> |
| <title>Fashion Classifier</title> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.44.0/tabler-icons.min.css"/> |
| <style> |
| *{box-sizing:border-box;margin:0;padding:0} |
| body{font-family:system-ui,sans-serif;background:#0a0c14;color:#e8eaf0;min-height:100vh;padding:2rem 1rem} |
| .star{position:fixed;border-radius:50%;background:#fff;opacity:0;animation:twinkle var(--d) var(--delay) infinite;pointer-events:none} |
| @keyframes twinkle{0%,100%{opacity:0}50%{opacity:var(--op)}} |
| .layout{max-width:1100px;margin:0 auto;display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start} |
| header{grid-column:1/-1;margin-bottom:.5rem} |
| .logo-row{display:flex;align-items:center;gap:12px;margin-bottom:.25rem} |
| .logo-dot{width:8px;height:8px;border-radius:50%;background:#5b6ee1} |
| h1{font-size:22px;font-weight:500;color:#e8eaf0;letter-spacing:.02em} |
| .subtitle{font-size:14px;color:#7a7f9a;line-height:1.6} |
| .card{background:#10131f;border:0.5px solid #1e2235;border-radius:14px;padding:1.5rem} |
| .upload-zone{border:1px dashed #2a2f4a;border-radius:10px;min-height:340px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;cursor:pointer;transition:border-color .2s,background .2s;position:relative;overflow:hidden} |
| .upload-zone:hover,.upload-zone.drag{border-color:#5b6ee1;background:#12152299} |
| .upload-icon{width:56px;height:56px;border-radius:50%;background:#161929;border:0.5px solid #1e2235;display:flex;align-items:center;justify-content:center;font-size:24px;color:#5b6ee1} |
| .upload-title{font-size:15px;font-weight:500;color:#c8cbde} |
| .upload-sub{font-size:13px;color:#5a5f78;text-align:center;line-height:1.6} |
| .upload-btn{padding:8px 20px;background:transparent;border:0.5px solid #2a2f4a;border-radius:8px;color:#a0a5c0;font-size:13px;cursor:pointer;transition:border-color .2s,color .2s} |
| .upload-btn:hover{border-color:#5b6ee1;color:#8090e8} |
| #preview-img{max-width:100%;max-height:280px;border-radius:8px;display:none;object-fit:contain} |
| .hint-box{margin-top:1rem;background:#0d1020;border:0.5px solid #1a1e30;border-left:2px solid #5b6ee1;border-radius:0 8px 8px 0;padding:.75rem 1rem;font-size:12.5px;color:#6a6f8a;line-height:1.65} |
| .hint-box strong{color:#8090e8;font-weight:500} |
| .section-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#3d4260;margin-bottom:.75rem;font-weight:500} |
| .predict-btn{width:100%;padding:11px;background:#1c2045;border:0.5px solid #2a3060;border-radius:10px;color:#8090e8;font-size:14px;font-weight:500;cursor:pointer;transition:background .2s,color .2s;margin-top:1rem} |
| .predict-btn:hover:not(:disabled){background:#232850;color:#aab4f0} |
| .predict-btn:disabled{opacity:.4;cursor:not-allowed} |
| .result-area{display:flex;flex-direction:column;gap:1rem} |
| .class-display{text-align:center;padding:1.25rem 1rem;background:#0d1020;border:0.5px solid #1e2235;border-radius:10px} |
| .class-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#3d4260;margin-bottom:.5rem} |
| .class-name{font-size:28px;font-weight:500;color:#c8cbde;line-height:1.2} |
| .class-id{font-size:12px;color:#3d4260;margin-top:.25rem} |
| .confidence-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem} |
| .conf-label{font-size:13px;color:#7a7f9a} |
| .conf-value{font-size:15px;font-weight:500;color:#8090e8} |
| .conf-bar-bg{height:6px;background:#161929;border-radius:3px;overflow:hidden} |
| .conf-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#3b4acc,#5b6ee1);transition:width .5s ease} |
| .probs-list{display:flex;flex-direction:column;gap:6px} |
| .prob-row{display:flex;align-items:center;gap:8px} |
| .prob-name{font-size:12px;color:#5a5f78;width:90px;flex-shrink:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} |
| .prob-bar-bg{flex:1;height:4px;background:#161929;border-radius:2px;overflow:hidden} |
| .prob-bar{height:100%;border-radius:2px;background:#2a3060;transition:width .4s ease} |
| .prob-bar.top{background:#5b6ee1} |
| .prob-pct{font-size:11px;color:#3d4260;width:34px;text-align:right;flex-shrink:0} |
| .model-meta{display:flex;flex-direction:column;gap:0} |
| .meta-row{display:flex;justify-content:space-between;align-items:center;font-size:12.5px;padding:7px 0;border-bottom:0.5px solid #12152299} |
| .meta-row:last-child{border-bottom:none} |
| .meta-key{color:#5a5f78} |
| .meta-val{color:#8090e8;font-weight:500} |
| .status-dot{width:6px;height:6px;border-radius:50%;background:#2a5c3a;display:inline-block;margin-right:6px;vertical-align:middle} |
| .status-dot.active{background:#3d9e5c} |
| .empty-state{text-align:center;padding:1.5rem 0;color:#3d4260;font-size:13px} |
| .empty-icon{font-size:28px;margin-bottom:.5rem;display:block} |
| input[type=file]{display:none} |
| @media(max-width:700px){.layout{grid-template-columns:1fr}} |
| </style> |
| </head> |
| <body> |
| <div id="stars"></div> |
| <div class="layout"> |
| <header> |
| <div class="logo-row"><div class="logo-dot"></div><h1>Fashion Classifier</h1></div> |
| <p class="subtitle">Support Vector Machine trained on Fashion-MNIST — 10 clothing categories, split 80/20 for training and evaluation. Upload an image to classify it.</p> |
| </header> |
|
|
| <div> |
| <div class="card"> |
| <div class="section-label">Image input</div> |
| <div class="upload-zone" id="drop-zone"> |
| <img id="preview-img" alt="Preview"/> |
| <div id="upload-placeholder"> |
| <div style="display:flex;flex-direction:column;align-items:center;gap:.75rem"> |
| <div class="upload-icon"><i class="ti ti-photo-up" aria-hidden="true"></i></div> |
| <div> |
| <div class="upload-title">Drop your image here</div> |
| <div class="upload-sub">PNG, JPG — ideally 28x28 px<br/>or larger (resized automatically)</div> |
| </div> |
| <button class="upload-btn" onclick="document.getElementById('file-input').click()"> |
| <i class="ti ti-upload" style="font-size:14px;vertical-align:-2px;margin-right:4px" aria-hidden="true"></i>Choose file |
| </button> |
| </div> |
| </div> |
| </div> |
| <input type="file" id="file-input" accept="image/*"/> |
| <div class="hint-box"> |
| <strong>For best results</strong> — the garment should appear on a <strong>plain white or light gray background</strong>, centered in the frame, with no other objects visible. Dark, patterned, or busy backgrounds significantly reduce classification accuracy. The model was trained on clean studio-style images. |
| </div> |
| <button class="predict-btn" id="predict-btn" disabled onclick="runPredict()"> |
| <i class="ti ti-cpu" style="font-size:15px;vertical-align:-2px;margin-right:6px" aria-hidden="true"></i>Run classification |
| </button> |
| </div> |
| </div> |
|
|
| <div style="display:flex;flex-direction:column;gap:1.25rem"> |
| <div class="card"> |
| <div class="section-label">Prediction</div> |
| <div class="result-area" id="result-area"> |
| <div class="empty-state"> |
| <span class="empty-icon"><i class="ti ti-satellite" aria-hidden="true"></i></span> |
| Upload an image to see the classification result |
| </div> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="section-label">Model details</div> |
| <div class="model-meta"> |
| <div class="meta-row"><span class="meta-key">Algorithm</span><span class="meta-val">SVM — RBF kernel</span></div> |
| <div class="meta-row"><span class="meta-key">Dimensionality reduction</span><span class="meta-val">PCA (95 % variance)</span></div> |
| <div class="meta-row"><span class="meta-key">Train / test split</span><span class="meta-val">80 % / 20 %</span></div> |
| <div class="meta-row"><span class="meta-key">Test accuracy</span><span class="meta-val" id="accuracy-val">90 %+</span></div> |
| <div class="meta-row"><span class="meta-key">Classes</span><span class="meta-val">10 categories</span></div> |
| <div class="meta-row"> |
| <span class="meta-key">API status</span> |
| <span class="meta-val"><span class="status-dot" id="status-dot"></span><span id="status-text">Checking...</span></span> |
| </div> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="section-label">Categories</div> |
| <div id="classes-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:4px 12px"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const CLASS_NAMES=["T-shirt / top","Trouser","Pullover","Dress","Coat","Sandal","Shirt","Sneaker","Bag","Ankle boot"]; |
| const API_BASE=window.location.origin; |
| const grid=document.getElementById("classes-grid"); |
| CLASS_NAMES.forEach((name,i)=>{ |
| const d=document.createElement("div"); |
| d.style.cssText="font-size:12px;color:#5a5f78;padding:4px 0;display:flex;align-items:center;gap:6px;border-bottom:0.5px solid #12152299"; |
| d.innerHTML=`<span style="font-size:11px;color:#3d4260;width:14px;flex-shrink:0">${i}</span>${name}`; |
| grid.appendChild(d); |
| }); |
| (function makeStars(){ |
| const c=document.getElementById("stars"); |
| for(let i=0;i<80;i++){ |
| const s=document.createElement("div"),sz=Math.random()*2+1; |
| s.className="star"; |
| s.style.cssText=`width:${sz}px;height:${sz}px;left:${Math.random()*100}vw;top:${Math.random()*100}vh;--d:${(Math.random()*4+2).toFixed(1)}s;--delay:${(Math.random()*5).toFixed(1)}s;--op:${(Math.random()*.6+.1).toFixed(2)}`; |
| c.appendChild(s); |
| } |
| })(); |
| async function checkHealth(){ |
| const dot=document.getElementById("status-dot"),txt=document.getElementById("status-text"); |
| try{ |
| const r=await fetch(`${API_BASE}/health`,{signal:AbortSignal.timeout(4000)}); |
| const d=await r.json(); |
| dot.classList.toggle("active",!!d.model_loaded); |
| txt.textContent=d.model_loaded?"Online":"Model not loaded"; |
| }catch(e){txt.textContent="Offline";} |
| } |
| checkHealth(); |
| const dropZone=document.getElementById("drop-zone"); |
| const fileInput=document.getElementById("file-input"); |
| const previewImg=document.getElementById("preview-img"); |
| const placeholder=document.getElementById("upload-placeholder"); |
| const predictBtn=document.getElementById("predict-btn"); |
| let currentPixels=null; |
| dropZone.addEventListener("dragover",e=>{e.preventDefault();dropZone.classList.add("drag")}); |
| dropZone.addEventListener("dragleave",()=>dropZone.classList.remove("drag")); |
| dropZone.addEventListener("drop",e=>{e.preventDefault();dropZone.classList.remove("drag");if(e.dataTransfer.files[0])handleFile(e.dataTransfer.files[0])}); |
| fileInput.addEventListener("change",()=>{if(fileInput.files[0])handleFile(fileInput.files[0])}); |
| dropZone.addEventListener("click",e=>{if(e.target===dropZone||e.target===previewImg)fileInput.click()}); |
| function handleFile(file){ |
| const reader=new FileReader(); |
| reader.onload=ev=>{ |
| previewImg.src=ev.target.result;previewImg.style.display="block";placeholder.style.display="none"; |
| extractPixels(ev.target.result); |
| }; |
| reader.readAsDataURL(file); |
| } |
| function extractPixels(dataUrl){ |
| const img=new Image(); |
| img.onload=()=>{ |
| const canvas=document.createElement("canvas");canvas.width=28;canvas.height=28; |
| const ctx=canvas.getContext("2d");ctx.drawImage(img,0,0,28,28); |
| const data=ctx.getImageData(0,0,28,28).data,pixels=[]; |
| for(let i=0;i<data.length;i+=4)pixels.push(Math.round((data[i]+data[i+1]+data[i+2])/3)); |
| currentPixels=pixels;predictBtn.disabled=false; |
| }; |
| img.src=dataUrl; |
| } |
| async function runPredict(){ |
| if(!currentPixels)return; |
| predictBtn.disabled=true; |
| predictBtn.innerHTML='<i class="ti ti-loader-2" style="font-size:15px;vertical-align:-2px;margin-right:6px"></i>Classifying...'; |
| const resultArea=document.getElementById("result-area"); |
| try{ |
| const r=await fetch(`${API_BASE}/predict`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({pixels:currentPixels})}); |
| const d=await r.json(); |
| if(d.error)throw new Error(d.error); |
| showResult(d); |
| }catch(e){ |
| resultArea.innerHTML=`<div class="empty-state" style="color:#7a3030"><span class="empty-icon"><i class="ti ti-alert-circle"></i></span>${e.message}</div>`; |
| }finally{ |
| predictBtn.disabled=false; |
| predictBtn.innerHTML='<i class="ti ti-cpu" style="font-size:15px;vertical-align:-2px;margin-right:6px"></i>Run classification'; |
| } |
| } |
| function showResult(d){ |
| const conf=Math.round(d.confidence*100); |
| const sorted=Object.entries(d.probabilities).sort((a,b)=>b[1]-a[1]); |
| const rows=sorted.map(([name,p])=>{ |
| const pct=Math.round(p*100),top=name===d.class_name; |
| return `<div class="prob-row"><span class="prob-name" title="${name}">${name}</span><div class="prob-bar-bg"><div class="prob-bar${top?" top":""}" style="width:${pct}%"></div></div><span class="prob-pct">${pct}%</span></div>`; |
| }).join(""); |
| document.getElementById("result-area").innerHTML=` |
| <div class="class-display"><div class="class-label">Classified as</div><div class="class-name">${d.class_name}</div><div class="class-id">Category ID: ${d.class_id}</div></div> |
| <div><div class="confidence-row"><span class="conf-label">Confidence</span><span class="conf-value">${conf}%</span></div><div class="conf-bar-bg"><div class="conf-bar-fill" style="width:${conf}%"></div></div></div> |
| <div><div class="section-label" style="margin-bottom:.6rem">All probabilities</div><div class="probs-list">${rows}</div></div> |
| ${d.inference_ms?`<div style="font-size:11px;color:#3d4260;text-align:right">Inference: ${d.inference_ms} ms</div>`:""} |
| `; |
| } |
| </script> |
| </body> |
| </html> |
|
|