ahmadarif019's picture
refactor: clean up app.py, slim API responses, document ONNX backend
7eeae6e
// ---------- Tabs ----------
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".pane").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.target).classList.add("active");
});
});
// ---------- Helpers ----------
function show(el) { el.classList.remove("hidden"); }
function hide(el) { el.classList.add("hidden"); }
function bindDropzone(zoneEl, inputEl, previewEl) {
inputEl.addEventListener("change", () => {
const file = inputEl.files && inputEl.files[0];
if (!file) {
zoneEl.classList.remove("has-file");
previewEl.removeAttribute("src");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
previewEl.src = e.target.result;
zoneEl.classList.add("has-file");
};
reader.readAsDataURL(file);
});
["dragenter", "dragover"].forEach((ev) => {
zoneEl.addEventListener(ev, (e) => {
e.preventDefault();
zoneEl.classList.add("dragover");
});
});
["dragleave", "drop"].forEach((ev) => {
zoneEl.addEventListener(ev, (e) => {
e.preventDefault();
zoneEl.classList.remove("dragover");
});
});
zoneEl.addEventListener("drop", (e) => {
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file) {
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
inputEl.dispatchEvent(new Event("change"));
}
});
}
// ---------- Detection ----------
const detectForm = document.getElementById("detectForm");
const detectFile = document.getElementById("detectFile");
const detectDrop = document.getElementById("detectDrop");
const detectPreview = document.getElementById("detectPreview");
const detectBtn = document.getElementById("detectBtn");
const detectLoader = document.getElementById("detectLoader");
const detectError = document.getElementById("detectError");
const detectEmpty = document.getElementById("detectEmpty");
const detectResult = document.getElementById("detectResult");
const detectCount = document.getElementById("detectCount");
const detectServerMs = document.getElementById("detectServerMs");
const detectTotalMs = document.getElementById("detectTotalMs");
const detectBackend = document.getElementById("detectBackend");
const detectImg = document.getElementById("detectImg");
const detectOverlay = document.getElementById("detectOverlay");
const detectActiveFace = document.getElementById("detectActiveFace");
const detectFacesLabel = document.getElementById("detectFacesLabel");
const detectNoFace = document.getElementById("detectNoFace");
const detectImgWrap = document.getElementById("detectImgWrap");
const detectFaceNav = document.getElementById("detectFaceNav");
const detectPrev = document.getElementById("detectPrev");
const detectNext = document.getElementById("detectNext");
const detectSelectedIdx = document.getElementById("detectSelectedIdx");
const detectTotal = document.getElementById("detectTotal");
let detectState = null;
bindDropzone(detectDrop, detectFile, detectPreview);
detectForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (!detectFile.files[0]) return;
hide(detectError);
hide(detectResult);
hide(detectEmpty);
show(detectLoader);
detectBtn.disabled = true;
const fd = new FormData(detectForm);
const t0 = performance.now();
try {
const res = await fetch("/api/detect", { method: "POST", body: fd });
const data = await res.json();
const totalMs = Math.round(performance.now() - t0);
if (!res.ok) throw new Error(data.error || "Request failed");
detectServerMs.textContent = data.processing_ms != null ? data.processing_ms + " ms" : "—";
detectTotalMs.textContent = totalMs + " ms";
detectBackend.textContent = data.backend || "—";
detectCount.textContent = data.face_count;
detectImg.src = data.image;
if (data.faces.length === 0) {
detectFacesLabel.classList.add("hidden");
detectFaceNav.classList.add("hidden");
detectActiveFace.classList.add("hidden");
detectImgWrap.classList.add("hidden");
detectNoFace.classList.remove("hidden");
detectOverlay.innerHTML = "";
detectState = null;
} else {
detectFacesLabel.classList.remove("hidden");
detectImgWrap.classList.remove("hidden");
detectNoFace.classList.add("hidden");
detectState = { data, selectedIdx: 0 };
renderDetectSelection(0);
}
show(detectResult);
} catch (err) {
detectError.textContent = err.message;
show(detectError);
show(detectEmpty);
} finally {
hide(detectLoader);
detectBtn.disabled = false;
}
});
// ---------- Compare ----------
const compareForm = document.getElementById("compareForm");
const compareFile1 = document.getElementById("compareFile1");
const compareFile2 = document.getElementById("compareFile2");
const compareDrop1 = document.getElementById("compareDrop1");
const compareDrop2 = document.getElementById("compareDrop2");
const comparePreview1 = document.getElementById("comparePreview1");
const comparePreview2 = document.getElementById("comparePreview2");
const compareBtn = document.getElementById("compareBtn");
const compareLoader = document.getElementById("compareLoader");
const compareError = document.getElementById("compareError");
const compareResult = document.getElementById("compareResult");
const compareTiming = document.getElementById("compareTiming");
const compareServerMs = document.getElementById("compareServerMs");
const compareTotalMs = document.getElementById("compareTotalMs");
const compareBackend = document.getElementById("compareBackend");
const compareVerdict = document.getElementById("compareVerdict");
const cmpSimilarity = document.getElementById("cmpSimilarity");
const metricsGrid = document.getElementById("metricsGrid");
const cmpCount1 = document.getElementById("cmpCount1");
const cmpCount2 = document.getElementById("cmpCount2");
const cmpImg1 = document.getElementById("cmpImg1");
const cmpImg2 = document.getElementById("cmpImg2");
const cmpOverlay1 = document.getElementById("cmpOverlay1");
const cmpOverlay2 = document.getElementById("cmpOverlay2");
const cmpCrop1 = document.getElementById("cmpCrop1");
const cmpCrop2 = document.getElementById("cmpCrop2");
const cmpIdx1 = document.getElementById("cmpIdx1");
const cmpIdx2 = document.getElementById("cmpIdx2");
const cmpFaceNav = document.getElementById("cmpFaceNav");
const cmpPrev = document.getElementById("cmpPrev");
const cmpNext = document.getElementById("cmpNext");
const cmpSelectedIdx = document.getElementById("cmpSelectedIdx");
const cmpTotal = document.getElementById("cmpTotal");
let cmpState = null;
const SVG_NS = "http://www.w3.org/2000/svg";
function renderOverlay(svgEl, w, h, boxes, activeIdx, withDataIdx) {
svgEl.setAttribute("viewBox", `0 0 ${w} ${h}`);
const baseStroke = Math.max(2, Math.min(w, h) * 0.005);
const labelH = Math.max(20, Math.min(w, h) * 0.035);
const labelW = labelH * 1.6;
const fontSize = labelH * 0.62;
svgEl.innerHTML = boxes.map((b, i) => {
const [x1, y1, x2, y2] = b;
const bw = x2 - x1, bh = y2 - y1;
const active = i === activeIdx;
const color = active ? "#22c55e" : "#ef4444";
const lblY = Math.max(0, y1 - labelH - 2);
const dataAttr = withDataIdx ? `data-face-idx="${i}"` : "";
return `
<g ${dataAttr}>
<rect x="${x1}" y="${y1}" width="${bw}" height="${bh}"
fill="transparent" stroke="${color}"
stroke-width="${active ? baseStroke * 1.8 : baseStroke}"
rx="${baseStroke}" />
<rect x="${x1}" y="${lblY}" width="${labelW}" height="${labelH}"
fill="${color}" rx="${labelH * 0.18}"/>
<text x="${x1 + labelW / 2}" y="${lblY + labelH * 0.72}"
fill="#fff" font-weight="700"
text-anchor="middle" font-size="${fontSize}"
font-family="JetBrains Mono, monospace">${i + 1}</text>
</g>`;
}).join("");
}
function renderSelection(idx) {
if (!cmpState) return;
const d = cmpState.data;
const total = d.face_count_2;
const sel = Math.max(0, Math.min(total - 1, idx));
cmpState.selectedIdx2 = sel;
const m = d.matches_2to1[sel];
const targetIdx1 = m.match_index;
const totalPairs = d.face_count_1 * d.face_count_2;
compareVerdict.classList.remove("success", "fail");
const subline = `WAJAH #${sel + 1} (G2) ↔ WAJAH #${targetIdx1 + 1} (G1) · ${totalPairs} TOTAL PASANGAN`;
if (m.verified) {
compareVerdict.classList.add("success");
compareVerdict.innerHTML = `${CHECK_ICON}<div><div class="verdict-title">Wajah cocok — kemungkinan orang yang sama</div><div class="verdict-sub">${subline}</div></div>`;
} else {
compareVerdict.classList.add("fail");
compareVerdict.innerHTML = `${X_ICON}<div><div class="verdict-title">Wajah tidak cocok — kemungkinan orang berbeda</div><div class="verdict-sub">${subline}</div></div>`;
}
renderOverlay(cmpOverlay1, d.image1_w, d.image1_h, d.boxes_1, targetIdx1, true);
renderOverlay(cmpOverlay2, d.image2_w, d.image2_h, d.boxes_2, sel, true);
cmpCrop1.src = d.crops_1[targetIdx1];
cmpCrop2.src = d.crops_2[sel];
cmpIdx1.textContent = targetIdx1 + 1;
cmpIdx2.textContent = sel + 1;
cmpSimilarity.textContent = m.similarity_percent.toFixed(2) + "%";
renderMetrics(m.metrics || []);
cmpSelectedIdx.textContent = sel + 1;
cmpTotal.textContent = total;
if (total > 1) {
cmpFaceNav.classList.remove("hidden");
} else {
cmpFaceNav.classList.add("hidden");
}
}
cmpPrev.addEventListener("click", () => {
if (!cmpState) return;
const total = cmpState.data.face_count_2;
renderSelection((cmpState.selectedIdx2 - 1 + total) % total);
});
cmpNext.addEventListener("click", () => {
if (!cmpState) return;
const total = cmpState.data.face_count_2;
renderSelection((cmpState.selectedIdx2 + 1) % total);
});
cmpOverlay2.addEventListener("click", (e) => {
const g = e.target.closest("[data-face-idx]");
if (!g) return;
const idx = parseInt(g.getAttribute("data-face-idx"), 10);
if (!Number.isNaN(idx)) renderSelection(idx);
});
cmpOverlay1.addEventListener("click", (e) => {
const g = e.target.closest("[data-face-idx]");
if (!g || !cmpState) return;
const idx1 = parseInt(g.getAttribute("data-face-idx"), 10);
if (Number.isNaN(idx1)) return;
const m = cmpState.data.matches_1to2;
if (m && idx1 < m.length) renderSelection(m[idx1]);
});
function renderDetectSelection(idx) {
if (!detectState) return;
const d = detectState.data;
const total = d.face_count;
if (total === 0) return;
const sel = Math.max(0, Math.min(total - 1, idx));
detectState.selectedIdx = sel;
const f = d.faces[sel];
const boxes = d.faces.map((face) => face.facial_area);
renderOverlay(detectOverlay, d.image_w, d.image_h, boxes, sel, true);
detectActiveFace.classList.remove("hidden");
detectActiveFace.innerHTML = `
<img src="${f.crop}" alt="face #${sel + 1}">
<div class="active-face-info">
<div class="active-face-num">#${sel + 1}</div>
<div class="active-face-stats">
<div><span class="stat-key">Score</span><span class="stat-val">${f.score.toFixed(4)}</span></div>
<div><span class="stat-key">Bounding Box</span><span class="stat-val">[${f.facial_area.join(", ")}]</span></div>
</div>
</div>`;
detectSelectedIdx.textContent = sel + 1;
detectTotal.textContent = total;
if (total > 1) {
detectFaceNav.classList.remove("hidden");
} else {
detectFaceNav.classList.add("hidden");
}
}
detectPrev.addEventListener("click", () => {
if (!detectState) return;
const total = detectState.data.face_count;
renderDetectSelection((detectState.selectedIdx - 1 + total) % total);
});
detectNext.addEventListener("click", () => {
if (!detectState) return;
const total = detectState.data.face_count;
renderDetectSelection((detectState.selectedIdx + 1) % total);
});
detectOverlay.addEventListener("click", (e) => {
const g = e.target.closest("[data-face-idx]");
if (!g) return;
const idx = parseInt(g.getAttribute("data-face-idx"), 10);
if (!Number.isNaN(idx)) renderDetectSelection(idx);
});
bindDropzone(compareDrop1, compareFile1, comparePreview1);
bindDropzone(compareDrop2, compareFile2, comparePreview2);
const CHECK_ICON = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>';
const X_ICON = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>';
function renderMetrics(metrics) {
metricsGrid.innerHTML = metrics.map((m, i) => {
const cls = m.verified === true ? "ok" : m.verified === false ? "no" : "unset";
const stateLabel = m.verified === true ? "MATCH" : m.verified === false ? "NO MATCH" : "INFO";
const thrText = m.threshold != null ? `thr ${m.threshold.toFixed(4)}` : "no threshold";
const simLine = m.similarity_percent != null ? `<div class="metric-card-sim">~ ${m.similarity_percent.toFixed(2)}% similar</div>` : "";
return `
<div class="metric-card ${cls}" style="animation-delay: ${i * 60}ms">
<div class="metric-card-label">${m.label}</div>
<div class="metric-card-value">${m.distance.toFixed(4)}</div>
${simLine}
<div class="metric-card-thr">${thrText}</div>
<span class="metric-card-state">${stateLabel}</span>
</div>`;
}).join("");
}
compareForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (!compareFile1.files[0] || !compareFile2.files[0]) return;
hide(compareError);
hide(compareResult);
hide(compareTiming);
show(compareLoader);
compareBtn.disabled = true;
const fd = new FormData(compareForm);
const t0 = performance.now();
try {
const res = await fetch("/api/compare", { method: "POST", body: fd });
const data = await res.json();
const totalMs = Math.round(performance.now() - t0);
compareServerMs.textContent = data.processing_ms != null ? data.processing_ms + " ms" : "—";
compareTotalMs.textContent = totalMs + " ms";
compareBackend.textContent = data.backend || "—";
show(compareTiming);
if (!res.ok) throw new Error(data.error || "Request failed");
cmpCount1.textContent = data.face_count_1;
cmpCount2.textContent = data.face_count_2;
cmpImg1.src = data.image1;
cmpImg2.src = data.image2;
cmpState = { data, selectedIdx2: data.best_match_index_2 };
renderSelection(data.best_match_index_2);
show(compareResult);
} catch (err) {
compareError.textContent = err.message;
show(compareError);
} finally {
hide(compareLoader);
compareBtn.disabled = false;
}
});