989 lines
38 KiB
JavaScript
989 lines
38 KiB
JavaScript
(() => {
|
||
const templatesData = JSON.parse(document.getElementById("templates-data").textContent);
|
||
const templateFileByName = {};
|
||
templatesData.forEach(t => { templateFileByName[t.name] = t.filename; });
|
||
|
||
const fileInput = document.getElementById("file-input");
|
||
const dropzone = document.getElementById("dropzone");
|
||
const dropzoneEmpty = document.getElementById("dropzone-empty");
|
||
const previewImg = document.getElementById("preview-img");
|
||
const submitBtn = document.getElementById("submit-btn");
|
||
const clearBtn = document.getElementById("clear-btn");
|
||
const errorMsg = document.getElementById("error-msg");
|
||
const loading = document.getElementById("loading");
|
||
const loadingText = document.getElementById("loading-text");
|
||
const resultsSection = document.getElementById("results");
|
||
const totalTimeEl = document.getElementById("total-time");
|
||
const overallCard = document.getElementById("overall-card");
|
||
const queryRow = document.getElementById("query-row");
|
||
const methodGrid = document.getElementById("method-grid");
|
||
const inputPalette = document.getElementById("input-palette");
|
||
const colorGrid = document.getElementById("color-grid");
|
||
const shapeGrid = document.getElementById("shape-grid");
|
||
const shapeVisual = document.getElementById("shape-visual");
|
||
const shapeOverall = document.getElementById("shape-overall");
|
||
const shapeVisualRow = document.getElementById("shape-visual-row");
|
||
const textureGrid = document.getElementById("texture-grid");
|
||
const textureVisual = document.getElementById("texture-visual");
|
||
const textureOverall = document.getElementById("texture-overall");
|
||
const textureVisualRow = document.getElementById("texture-visual-row");
|
||
const textureGlcmTable = document.getElementById("texture-glcm-table");
|
||
const weightedSub = document.getElementById("weighted-sub");
|
||
const weightedCard = document.getElementById("weighted-card");
|
||
const weightedRanked = document.getElementById("weighted-ranked");
|
||
const weightedFlowerCheck = document.getElementById("weighted-flower-check");
|
||
const weightedFlowerCheckLoading = document.getElementById("weighted-flower-check-loading");
|
||
const weightedFlowerCheckBody = document.getElementById("weighted-flower-check-body");
|
||
const weightedFlowerCheckError = document.getElementById("weighted-flower-check-error");
|
||
const wfcCountValue = document.getElementById("wfc-count-value");
|
||
const wfcClipValue = document.getElementById("wfc-clip-value");
|
||
const familyGridSection = document.getElementById("family-grid-section");
|
||
const familyGridTemplateName = document.getElementById("family-grid-template-name");
|
||
const familyGridOverall = document.getElementById("family-grid-overall");
|
||
const familyGridRow = document.getElementById("family-grid-row");
|
||
const familyGridMatches = document.getElementById("family-grid-matches");
|
||
const verifySection = document.getElementById("verify-section");
|
||
const verifyTemplateName = document.getElementById("verify-template-name");
|
||
const verifyGlass = document.getElementById("verify-glass");
|
||
const countFlowersBtn = document.getElementById("count-flowers-btn");
|
||
const flowerCountLoading = document.getElementById("flower-count-loading");
|
||
const flowerCountError = document.getElementById("flower-count-error");
|
||
const flowerCountResult = document.getElementById("flower-count-result");
|
||
const flowerCountImage = document.getElementById("flower-count-image");
|
||
const flowerCountTotal = document.getElementById("flower-count-total");
|
||
const flowerCountClusters = document.getElementById("flower-count-clusters");
|
||
const flowerCountTime = document.getElementById("flower-count-time");
|
||
const yoloCountImage = document.getElementById("yolo-count-image");
|
||
const yoloCountTotal = document.getElementById("yolo-count-total");
|
||
const vaseCompare = document.getElementById("vase-compare");
|
||
const vaseCompareError = document.getElementById("vase-compare-error");
|
||
const vaseCompareBody = document.getElementById("vase-compare-body");
|
||
const vaseCropInput = document.getElementById("vase-crop-input");
|
||
const vaseCropTemplate = document.getElementById("vase-crop-template");
|
||
const vaseCropTemplateName = document.getElementById("vase-crop-template-name");
|
||
const vaseVerdictPill = document.getElementById("vase-verdict-pill");
|
||
const vaseDinoBar = document.getElementById("vase-dino-bar");
|
||
const vaseDinoValue = document.getElementById("vase-dino-value");
|
||
const vaseClipBar = document.getElementById("vase-clip-bar");
|
||
const vaseClipValue = document.getElementById("vase-clip-value");
|
||
const vaseCombinedBar = document.getElementById("vase-combined-bar");
|
||
const vaseCombinedValue = document.getElementById("vase-combined-value");
|
||
const flowerCountMismatch = document.getElementById("flower-count-mismatch");
|
||
const flowerCountMismatchText = document.getElementById("flower-count-mismatch-text");
|
||
|
||
let selectedFile = null;
|
||
let currentRequestId = null;
|
||
let currentWeightedBest = null;
|
||
|
||
function showError(msg) {
|
||
errorMsg.textContent = msg;
|
||
errorMsg.hidden = !msg;
|
||
}
|
||
|
||
function setSelectedFile(file) {
|
||
if (!file) return;
|
||
if (!file.type.startsWith("image/")) {
|
||
showError("Please choose an image file.");
|
||
return;
|
||
}
|
||
showError("");
|
||
selectedFile = file;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
previewImg.src = e.target.result;
|
||
previewImg.hidden = false;
|
||
dropzoneEmpty.hidden = true;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
|
||
submitBtn.disabled = false;
|
||
clearBtn.hidden = false;
|
||
}
|
||
|
||
fileInput.addEventListener("change", () => setSelectedFile(fileInput.files[0]));
|
||
|
||
["dragover", "dragenter"].forEach(evt =>
|
||
dropzone.addEventListener(evt, e => {
|
||
e.preventDefault();
|
||
dropzone.classList.add("dragover");
|
||
})
|
||
);
|
||
["dragleave", "drop"].forEach(evt =>
|
||
dropzone.addEventListener(evt, e => {
|
||
e.preventDefault();
|
||
dropzone.classList.remove("dragover");
|
||
})
|
||
);
|
||
dropzone.addEventListener("drop", e => {
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) setSelectedFile(file);
|
||
});
|
||
|
||
clearBtn.addEventListener("click", e => {
|
||
e.preventDefault();
|
||
selectedFile = null;
|
||
fileInput.value = "";
|
||
previewImg.hidden = true;
|
||
dropzoneEmpty.hidden = false;
|
||
submitBtn.disabled = true;
|
||
clearBtn.hidden = true;
|
||
showError("");
|
||
resultsSection.hidden = true;
|
||
});
|
||
|
||
const LOADING_MESSAGES = [
|
||
"Removing background…",
|
||
"Extracting SIFT & ORB keypoints…",
|
||
"Running SuperPoint + LightGlue…",
|
||
"Running LoFTR…",
|
||
"Scoring templates…",
|
||
];
|
||
|
||
function cycleLoadingMessages() {
|
||
let i = 0;
|
||
loadingText.textContent = LOADING_MESSAGES[0];
|
||
return setInterval(() => {
|
||
i = (i + 1) % LOADING_MESSAGES.length;
|
||
loadingText.textContent = LOADING_MESSAGES[i];
|
||
}, 1400);
|
||
}
|
||
|
||
submitBtn.addEventListener("click", async () => {
|
||
if (!selectedFile) return;
|
||
showError("");
|
||
submitBtn.disabled = true;
|
||
resultsSection.hidden = true;
|
||
loading.hidden = false;
|
||
const msgTimer = cycleLoadingMessages();
|
||
|
||
try {
|
||
const form = new FormData();
|
||
form.append("image", selectedFile);
|
||
|
||
const res = await fetch("/api/match", { method: "POST", body: form });
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.error || "Something went wrong.");
|
||
}
|
||
|
||
renderResults(data);
|
||
} catch (err) {
|
||
showError(err.message || String(err));
|
||
} finally {
|
||
clearInterval(msgTimer);
|
||
loading.hidden = true;
|
||
submitBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function uploadUrl(requestId, filename) {
|
||
return `/uploads/${requestId}/${filename}`;
|
||
}
|
||
|
||
function renderResults(data) {
|
||
currentRequestId = data.request_id;
|
||
currentWeightedBest = data.weighted_best;
|
||
flowerCountResult.hidden = true;
|
||
flowerCountError.hidden = true;
|
||
flowerCountLoading.hidden = true;
|
||
vaseCompare.hidden = true;
|
||
flowerCountMismatch.hidden = true;
|
||
weightedFlowerCheck.hidden = true;
|
||
countFlowersBtn.disabled = false;
|
||
countFlowersBtn.textContent = "Count flowers";
|
||
|
||
totalTimeEl.textContent =
|
||
`Total processing time: ${data.total_time_sec.toFixed(2)}s ` +
|
||
`(background removal: ${data.bg_removal_time_sec.toFixed(2)}s)`;
|
||
|
||
// --- weighted final match (rendered first -- it now leads #results so
|
||
// the final verdict is visible without scrolling past every card) ---
|
||
renderWeighted(data);
|
||
|
||
// --- overall best ---
|
||
overallCard.innerHTML = "";
|
||
if (data.overall_best) {
|
||
const filename = templateFileByName[data.overall_best];
|
||
if (filename) {
|
||
const img = el("img", "overall-thumb");
|
||
img.src = `/template_image/${encodeURIComponent(filename)}`;
|
||
img.alt = data.overall_best;
|
||
overallCard.appendChild(img);
|
||
}
|
||
const textWrap = el("div", "overall-text");
|
||
textWrap.appendChild(el("p", "label", "Overall best match"));
|
||
textWrap.appendChild(el("h3", null, data.overall_best));
|
||
overallCard.appendChild(textWrap);
|
||
} else {
|
||
overallCard.appendChild(el("p", null, "No confident match found across any method."));
|
||
}
|
||
|
||
// --- query row: original + background removed ---
|
||
queryRow.innerHTML = "";
|
||
queryRow.appendChild(buildQueryTile(
|
||
uploadUrl(data.request_id, data.upload_original_file), "Your upload"
|
||
));
|
||
queryRow.appendChild(buildQueryTile(
|
||
uploadUrl(data.request_id, data.upload_nobg_file), "Background removed"
|
||
));
|
||
|
||
// --- per-method cards (Color is excluded here -- it has its own
|
||
// dedicated section below and only participates in the weighted
|
||
// verdict, not this grid) ---
|
||
methodGrid.innerHTML = "";
|
||
Object.entries(data.methods).forEach(([methodKey, m]) => {
|
||
if (methodKey === "Color") return;
|
||
methodGrid.appendChild(buildMethodCard(data.request_id, methodKey, m));
|
||
});
|
||
|
||
// --- color space section (own display; also weighted into the verdict) ---
|
||
renderColorAnalysis(data);
|
||
|
||
// --- shape matching + texture matching (independent sections, no
|
||
// score/verdict involvement at all) ---
|
||
renderShapeAnalysis(data);
|
||
renderTextureAnalysis(data);
|
||
|
||
// --- color family grid (visual, tied to the weighted-best template) ---
|
||
renderFamilyGrid(data);
|
||
|
||
resultsSection.hidden = false;
|
||
resultsSection.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
|
||
// --- AI verification (fires after everything above is already on
|
||
// screen; a slow or unreachable external endpoint should never block
|
||
// or affect the core match results) ---
|
||
if (data.weighted_best) {
|
||
runVerification(data, data.weighted_best);
|
||
runFlowerSummary(data.request_id, data.weighted_best);
|
||
}
|
||
}
|
||
|
||
async function runFlowerSummary(requestId, template) {
|
||
weightedFlowerCheck.hidden = false;
|
||
weightedFlowerCheckLoading.hidden = false;
|
||
weightedFlowerCheckBody.hidden = true;
|
||
weightedFlowerCheckError.hidden = true;
|
||
|
||
try {
|
||
const res = await fetch("/api/flower_summary", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ request_id: requestId, template }),
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.error || "Flower check failed.");
|
||
}
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
const fc = data.flower_count_comparison;
|
||
wfcCountValue.textContent = fc ? `${fc.input_count} vs ${fc.template_count}` : "n/a";
|
||
wfcCountValue.title = fc ? fc.message : "";
|
||
wfcClipValue.textContent = data.flower_clip_similarity_pct != null
|
||
? `${data.flower_clip_similarity_pct}%` : "n/a";
|
||
|
||
weightedFlowerCheckLoading.hidden = true;
|
||
weightedFlowerCheckBody.hidden = false;
|
||
} catch (err) {
|
||
weightedFlowerCheckLoading.hidden = true;
|
||
weightedFlowerCheckError.textContent = err.message || String(err);
|
||
weightedFlowerCheckError.hidden = false;
|
||
}
|
||
}
|
||
|
||
async function runVerification(matchData, template) {
|
||
verifySection.hidden = false;
|
||
verifyTemplateName.textContent = template;
|
||
|
||
verifyGlass.innerHTML = "";
|
||
const loadingWrap = el("div", "verify-loading");
|
||
loadingWrap.appendChild(el("div", "spinner"));
|
||
loadingWrap.appendChild(el("p", null, "Cross-checking with an AI vision model…"));
|
||
verifyGlass.appendChild(loadingWrap);
|
||
|
||
try {
|
||
const res = await fetch("/api/verify", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ request_id: matchData.request_id, template }),
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.error || "Verification failed.");
|
||
}
|
||
|
||
renderVerification(matchData, template, data);
|
||
} catch (err) {
|
||
verifyGlass.innerHTML = "";
|
||
verifyGlass.appendChild(el("p", "verify-error",
|
||
`AI verification is unavailable right now (${err.message || err}).`));
|
||
}
|
||
}
|
||
|
||
const DESCRIPTION_PREVIEW_LEN = 220;
|
||
|
||
function combinedDescription(data) {
|
||
const hasDiscrepancies = data.discrepancies && data.discrepancies.toLowerCase() !== "none";
|
||
if (data.description && hasDiscrepancies) {
|
||
return `${data.description} Discrepancies: ${data.discrepancies}`;
|
||
}
|
||
if (data.description) return data.description;
|
||
if (hasDiscrepancies) return `Discrepancies: ${data.discrepancies}`;
|
||
return "No discrepancies were found between the two images.";
|
||
}
|
||
|
||
function renderVerification(matchData, template, data) {
|
||
verifyGlass.innerHTML = "";
|
||
|
||
// --- the two photos being compared ---
|
||
const imagesRow = el("div", "verify-images");
|
||
const uploadTile = el("div", "verify-image-tile");
|
||
const uploadImg = el("img");
|
||
uploadImg.src = uploadUrl(matchData.request_id, matchData.upload_original_file);
|
||
uploadImg.alt = "Your upload";
|
||
uploadTile.appendChild(uploadImg);
|
||
uploadTile.appendChild(el("span", null, "Your upload"));
|
||
imagesRow.appendChild(uploadTile);
|
||
|
||
imagesRow.appendChild(el("span", "verify-vs", "vs"));
|
||
|
||
const templateTile = el("div", "verify-image-tile");
|
||
const templateFilename = templateFileByName[template];
|
||
if (templateFilename) {
|
||
const templateImg = el("img");
|
||
templateImg.src = `/template_image/${encodeURIComponent(templateFilename)}`;
|
||
templateImg.alt = template;
|
||
templateTile.appendChild(templateImg);
|
||
}
|
||
templateTile.appendChild(el("span", null, template));
|
||
imagesRow.appendChild(templateTile);
|
||
|
||
verifyGlass.appendChild(imagesRow);
|
||
|
||
// --- Match: Yes/No/Partial ---
|
||
const matchKey = (data.match || "unknown").toLowerCase();
|
||
const matchValue = { yes: "Yes", no: "No", partial: "Partial" }[matchKey] || "Unclear";
|
||
const matchLine = el("p", `verify-match-line ${matchKey}`);
|
||
matchLine.appendChild(el("span", "match-label", "Match: "));
|
||
matchLine.appendChild(el("span", "match-value", matchValue));
|
||
if (data.confidence) {
|
||
matchLine.appendChild(el("span", "verify-confidence", `Confidence: ${data.confidence}`));
|
||
}
|
||
verifyGlass.appendChild(matchLine);
|
||
|
||
// --- Description: truncated, with a Read more / Show less toggle ---
|
||
const fullText = combinedDescription(data);
|
||
const descWrap = el("p", "verify-description");
|
||
const label = el("span", "match-label", "Description: ");
|
||
const textSpan = el("span", "verify-description-text");
|
||
descWrap.appendChild(label);
|
||
descWrap.appendChild(textSpan);
|
||
verifyGlass.appendChild(descWrap);
|
||
|
||
if (fullText.length <= DESCRIPTION_PREVIEW_LEN) {
|
||
textSpan.textContent = fullText;
|
||
} else {
|
||
let expanded = false;
|
||
const toggle = el("a", "read-more-toggle", "Read more");
|
||
toggle.href = "#";
|
||
const renderText = () => {
|
||
textSpan.textContent = expanded
|
||
? fullText + " "
|
||
: fullText.slice(0, DESCRIPTION_PREVIEW_LEN).trim() + "… ";
|
||
toggle.textContent = expanded ? "Show less" : "Read more";
|
||
};
|
||
toggle.addEventListener("click", e => {
|
||
e.preventDefault();
|
||
expanded = !expanded;
|
||
renderText();
|
||
});
|
||
renderText();
|
||
descWrap.appendChild(toggle);
|
||
}
|
||
|
||
if (data.pixel_precheck) {
|
||
const { hash_distance, verdict } = data.pixel_precheck;
|
||
verifyGlass.appendChild(el("p", "verify-footnote",
|
||
`Pixel pre-check: ${verdict} (hash distance ${hash_distance})`));
|
||
}
|
||
}
|
||
|
||
countFlowersBtn.addEventListener("click", async () => {
|
||
if (!currentRequestId) return;
|
||
countFlowersBtn.disabled = true;
|
||
flowerCountError.hidden = true;
|
||
flowerCountResult.hidden = true;
|
||
flowerCountLoading.hidden = false;
|
||
|
||
try {
|
||
const res = await fetch("/api/count_flowers", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ request_id: currentRequestId, template: currentWeightedBest }),
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.error || "Flower counting failed.");
|
||
}
|
||
|
||
renderFlowerCount(data);
|
||
} catch (err) {
|
||
flowerCountError.textContent = err.message || String(err);
|
||
flowerCountError.hidden = false;
|
||
} finally {
|
||
flowerCountLoading.hidden = true;
|
||
countFlowersBtn.disabled = false;
|
||
countFlowersBtn.textContent = "Recount";
|
||
}
|
||
});
|
||
|
||
function renderFlowerCount(data) {
|
||
const sam = data.sam || {};
|
||
const yolo = data.yolo || {};
|
||
|
||
if (sam.error && yolo.error) {
|
||
flowerCountError.textContent = sam.error || yolo.error;
|
||
flowerCountError.hidden = false;
|
||
return;
|
||
}
|
||
|
||
if (!sam.error) {
|
||
flowerCountImage.hidden = false;
|
||
flowerCountImage.src = `${uploadUrl(data.request_id, sam.visual_file)}?t=${Date.now()}`;
|
||
flowerCountTotal.textContent = `${sam.total_count} flower${sam.total_count === 1 ? "" : "s"} detected`;
|
||
|
||
flowerCountClusters.innerHTML = "";
|
||
(sam.clusters || []).forEach(c => {
|
||
const chip = el("span", "flower-cluster-chip");
|
||
const swatch = el("span", "swatch");
|
||
swatch.style.background = rgbCss(c.color_rgb);
|
||
chip.appendChild(swatch);
|
||
chip.appendChild(el("span", null, `${c.count}`));
|
||
flowerCountClusters.appendChild(chip);
|
||
});
|
||
if ((sam.clusters || []).length > 1) {
|
||
flowerCountClusters.appendChild(
|
||
el("span", "flower-cluster-hint", `~${sam.clusters.length} distinct kinds (by color)`)
|
||
);
|
||
}
|
||
} else {
|
||
flowerCountImage.hidden = true;
|
||
flowerCountTotal.textContent = `SAM unavailable: ${sam.error}`;
|
||
flowerCountClusters.innerHTML = "";
|
||
}
|
||
|
||
if (!yolo.error) {
|
||
yoloCountImage.hidden = false;
|
||
yoloCountImage.src = `${uploadUrl(data.request_id, yolo.visual_file)}?t=${Date.now()}`;
|
||
const parts = [`${yolo.flower_count} flower region${yolo.flower_count === 1 ? "" : "s"}`];
|
||
if (yolo.vase_count) parts.push(`${yolo.vase_count} vase`);
|
||
if (yolo.ribbon_count) parts.push(`${yolo.ribbon_count} ribbon/bow`);
|
||
yoloCountTotal.textContent = parts.join(" · ");
|
||
} else {
|
||
yoloCountImage.hidden = true;
|
||
yoloCountTotal.textContent = `YOLO-World unavailable: ${yolo.error}`;
|
||
}
|
||
|
||
flowerCountTime.hidden = false;
|
||
flowerCountTime.textContent = `Processed in ${data.time_sec.toFixed(2)}s`;
|
||
flowerCountResult.hidden = false;
|
||
|
||
renderFlowerCountMismatch(data.flower_count_comparison);
|
||
renderVaseComparison(data.vase_comparison);
|
||
}
|
||
|
||
function renderFlowerCountMismatch(fc) {
|
||
if (!fc) {
|
||
flowerCountMismatch.hidden = true;
|
||
return;
|
||
}
|
||
flowerCountMismatch.hidden = false;
|
||
flowerCountMismatchText.textContent = fc.error
|
||
? `Flower-count comparison unavailable: ${fc.error}`
|
||
: fc.message;
|
||
flowerCountMismatch.classList.toggle("is-mismatch", !fc.error && fc.diff !== 0);
|
||
}
|
||
|
||
function renderVaseComparison(vc) {
|
||
if (!vc) {
|
||
vaseCompare.hidden = true;
|
||
return;
|
||
}
|
||
vaseCompare.hidden = false;
|
||
|
||
if (vc.error) {
|
||
vaseCompareError.textContent = vc.error;
|
||
vaseCompareError.hidden = false;
|
||
vaseCompareBody.hidden = true;
|
||
return;
|
||
}
|
||
vaseCompareError.hidden = true;
|
||
vaseCompareBody.hidden = false;
|
||
|
||
vaseCropInput.src = `${uploadUrl(currentRequestId, vc.input_crop_file)}?t=${Date.now()}`;
|
||
vaseCropTemplate.src = `${uploadUrl(currentRequestId, vc.template_crop_file)}?t=${Date.now()}`;
|
||
vaseCropTemplateName.textContent = vc.template;
|
||
|
||
const verdictLabel = { same: "Same vase", uncertain: "Uncertain", different: "Different vase" }[vc.verdict]
|
||
|| vc.verdict;
|
||
vaseVerdictPill.textContent = `${verdictLabel} — ${vc.combined_pct}% combined similarity`;
|
||
vaseVerdictPill.className = `vase-verdict-pill ${vc.verdict}`;
|
||
|
||
vaseDinoBar.style.width = `${vc.dino_similarity_pct}%`;
|
||
vaseDinoValue.textContent = `${vc.dino_similarity_pct}%`;
|
||
vaseClipBar.style.width = `${vc.clip_similarity_pct}%`;
|
||
vaseClipValue.textContent = `${vc.clip_similarity_pct}%`;
|
||
vaseCombinedBar.style.width = `${vc.combined_pct}%`;
|
||
vaseCombinedValue.textContent = `${vc.combined_pct}%`;
|
||
}
|
||
|
||
function rgbCss(rgb) {
|
||
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||
}
|
||
|
||
function renderColorAnalysis(data) {
|
||
const analysis = data.color_analysis || { input_dominant_colors: [], templates: [] };
|
||
|
||
inputPalette.innerHTML = "";
|
||
if (analysis.input_dominant_colors.length) {
|
||
inputPalette.appendChild(el("span", "palette-label", "Your photo's colors:"));
|
||
analysis.input_dominant_colors.forEach(c => {
|
||
const chip = el("span", "swatch-chip");
|
||
const swatch = el("span", "swatch");
|
||
swatch.style.background = rgbCss(c.rgb);
|
||
chip.appendChild(swatch);
|
||
chip.appendChild(document.createTextNode(`${c.pct}%`));
|
||
inputPalette.appendChild(chip);
|
||
});
|
||
} else if (data.color_analysis_error) {
|
||
inputPalette.appendChild(el("p", "verify-error",
|
||
`Color analysis unavailable (${data.color_analysis_error}).`));
|
||
}
|
||
|
||
colorGrid.innerHTML = "";
|
||
analysis.templates.forEach(t => {
|
||
colorGrid.appendChild(buildColorCard(t));
|
||
});
|
||
}
|
||
|
||
function buildColorCard(t) {
|
||
const card = el("div", "color-card");
|
||
|
||
const head = el("div", "color-card-head");
|
||
const filename = templateFileByName[t.template];
|
||
if (filename) {
|
||
const img = el("img");
|
||
img.src = `/template_image/${encodeURIComponent(filename)}`;
|
||
img.alt = t.template;
|
||
head.appendChild(img);
|
||
}
|
||
head.appendChild(el("span", "name", t.template));
|
||
card.appendChild(head);
|
||
|
||
const matchRow = el("div", "color-match-row");
|
||
const track = el("div", "score-bar-track");
|
||
const fill = el("div", "score-bar-fill");
|
||
fill.style.width = `${Math.max(2, t.match_pct)}%`;
|
||
track.appendChild(fill);
|
||
matchRow.appendChild(track);
|
||
matchRow.appendChild(el("span", "match-pct", `${t.match_pct}% match`));
|
||
card.appendChild(matchRow);
|
||
|
||
const pairs = el("div", "color-pairs");
|
||
t.color_pairs.forEach(p => {
|
||
const pair = el("div", "color-pair");
|
||
const swatches = el("div", "pair-swatches");
|
||
|
||
const inputSwatch = el("span", "swatch");
|
||
inputSwatch.style.background = rgbCss(p.input_rgb);
|
||
inputSwatch.title = `Your photo — ${p.input_pct}%`;
|
||
swatches.appendChild(inputSwatch);
|
||
|
||
swatches.appendChild(el("span", "pair-arrow", "→"));
|
||
|
||
const templateSwatch = el("span", "swatch");
|
||
templateSwatch.style.background = rgbCss(p.template_rgb);
|
||
templateSwatch.title = `${t.template} — ${p.template_pct}%`;
|
||
swatches.appendChild(templateSwatch);
|
||
|
||
pair.appendChild(swatches);
|
||
pair.appendChild(el("span", "pair-similarity", `${p.similarity}% alike`));
|
||
pairs.appendChild(pair);
|
||
});
|
||
card.appendChild(pairs);
|
||
|
||
return card;
|
||
}
|
||
|
||
function renderShapeAnalysis(data) {
|
||
const analysis = data.shape_analysis || { results: [], visuals: null, error: null };
|
||
|
||
shapeGrid.innerHTML = "";
|
||
if (!analysis.results.length && analysis.error) {
|
||
shapeGrid.appendChild(el("p", "verify-error", `Shape analysis unavailable (${analysis.error}).`));
|
||
} else {
|
||
analysis.results.forEach(t => shapeGrid.appendChild(buildShapeCard(t)));
|
||
}
|
||
|
||
const best = analysis.results[0];
|
||
if (analysis.visuals && best && data.weighted_best) {
|
||
shapeVisual.hidden = false;
|
||
shapeOverall.innerHTML = "";
|
||
const wrap = el("div");
|
||
const bestForWeighted = analysis.results.find(r => r.template === data.weighted_best) || best;
|
||
wrap.appendChild(el("span", "big-pct", `${bestForWeighted.match_pct}%`));
|
||
wrap.appendChild(el("span", "big-pct-label", `Overall shape match vs ${data.weighted_best}`));
|
||
shapeOverall.appendChild(wrap);
|
||
|
||
shapeVisualRow.innerHTML = "";
|
||
shapeVisualRow.appendChild(buildGridTile(
|
||
uploadUrl(data.request_id, analysis.visuals.input_file), "Your upload"));
|
||
shapeVisualRow.appendChild(buildGridTile(
|
||
uploadUrl(data.request_id, analysis.visuals.template_file), data.weighted_best));
|
||
shapeVisualRow.appendChild(buildGridTile(
|
||
uploadUrl(data.request_id, analysis.visuals.overlay_file), "Overlap"));
|
||
} else {
|
||
shapeVisual.hidden = true;
|
||
}
|
||
}
|
||
|
||
function buildShapeCard(t) {
|
||
const card = el("div", "shape-card");
|
||
|
||
const head = el("div", "shape-card-head");
|
||
const filename = templateFileByName[t.template];
|
||
if (filename) {
|
||
const img = el("img");
|
||
img.src = `/template_image/${encodeURIComponent(filename)}`;
|
||
img.alt = t.template;
|
||
head.appendChild(img);
|
||
}
|
||
head.appendChild(el("span", "name", t.template));
|
||
card.appendChild(head);
|
||
|
||
const matchRow = el("div", "shape-match-row");
|
||
const track = el("div", "score-bar-track");
|
||
const fill = el("div", "score-bar-fill");
|
||
fill.style.width = `${Math.max(2, t.match_pct)}%`;
|
||
track.appendChild(fill);
|
||
matchRow.appendChild(track);
|
||
matchRow.appendChild(el("span", "match-pct", `${t.match_pct}% match`));
|
||
card.appendChild(matchRow);
|
||
|
||
card.appendChild(el("p", "shape-subscores",
|
||
`Hu-moment similarity ${t.hu_similarity_pct}% · silhouette overlap ${t.iou_pct}%`));
|
||
|
||
return card;
|
||
}
|
||
|
||
function renderTextureAnalysis(data) {
|
||
const analysis = data.texture_analysis || { results: [], visuals: null, error: null };
|
||
|
||
textureGrid.innerHTML = "";
|
||
if (!analysis.results.length && analysis.error) {
|
||
textureGrid.appendChild(el("p", "verify-error", `Texture analysis unavailable (${analysis.error}).`));
|
||
} else {
|
||
analysis.results.forEach(t => textureGrid.appendChild(buildTextureCard(t)));
|
||
}
|
||
|
||
const best = analysis.results[0];
|
||
const bestForWeighted = data.weighted_best
|
||
? analysis.results.find(r => r.template === data.weighted_best)
|
||
: null;
|
||
|
||
if (analysis.visuals && best && bestForWeighted) {
|
||
textureVisual.hidden = false;
|
||
textureOverall.innerHTML = "";
|
||
const wrap = el("div");
|
||
wrap.appendChild(el("span", "big-pct", `${bestForWeighted.match_pct}%`));
|
||
wrap.appendChild(el("span", "big-pct-label", `Overall texture match vs ${data.weighted_best}`));
|
||
textureOverall.appendChild(wrap);
|
||
|
||
textureVisualRow.innerHTML = "";
|
||
textureVisualRow.appendChild(buildGridTile(
|
||
uploadUrl(data.request_id, analysis.visuals.input_file), "Your upload (LBP)"));
|
||
textureVisualRow.appendChild(buildGridTile(
|
||
uploadUrl(data.request_id, analysis.visuals.template_file), `${data.weighted_best} (LBP)`));
|
||
|
||
textureGlcmTable.innerHTML = "";
|
||
const head = el("div", "row head");
|
||
head.appendChild(el("span", "prop-name", "GLCM property"));
|
||
head.appendChild(el("span", "value", "Yours"));
|
||
head.appendChild(el("span", "value", data.weighted_best));
|
||
head.appendChild(el("span", "value", "Alike"));
|
||
textureGlcmTable.appendChild(head);
|
||
|
||
Object.keys(bestForWeighted.input_glcm_features).forEach(prop => {
|
||
const a = bestForWeighted.input_glcm_features[prop];
|
||
const b = bestForWeighted.template_glcm_features[prop];
|
||
const scale = Math.max(Math.abs(a), Math.abs(b), 1e-9);
|
||
const alike = Math.max(0, 100 * (1 - Math.abs(a - b) / scale));
|
||
const row = el("div", "row");
|
||
row.appendChild(el("span", "prop-name", prop));
|
||
row.appendChild(el("span", "value", String(a)));
|
||
row.appendChild(el("span", "value", String(b)));
|
||
row.appendChild(el("span", "value", `${alike.toFixed(1)}%`));
|
||
textureGlcmTable.appendChild(row);
|
||
});
|
||
} else {
|
||
textureVisual.hidden = true;
|
||
}
|
||
}
|
||
|
||
function buildTextureCard(t) {
|
||
const card = el("div", "texture-card");
|
||
|
||
const head = el("div", "texture-card-head");
|
||
const filename = templateFileByName[t.template];
|
||
if (filename) {
|
||
const img = el("img");
|
||
img.src = `/template_image/${encodeURIComponent(filename)}`;
|
||
img.alt = t.template;
|
||
head.appendChild(img);
|
||
}
|
||
head.appendChild(el("span", "name", t.template));
|
||
card.appendChild(head);
|
||
|
||
const matchRow = el("div", "texture-match-row");
|
||
const track = el("div", "score-bar-track");
|
||
const fill = el("div", "score-bar-fill");
|
||
fill.style.width = `${Math.max(2, t.match_pct)}%`;
|
||
track.appendChild(fill);
|
||
matchRow.appendChild(track);
|
||
matchRow.appendChild(el("span", "match-pct", `${t.match_pct}% match`));
|
||
card.appendChild(matchRow);
|
||
|
||
card.appendChild(el("p", "texture-subscores",
|
||
`LBP pattern ${t.lbp_similarity_pct}% · GLCM statistics ${t.glcm_similarity_pct}%`));
|
||
|
||
return card;
|
||
}
|
||
|
||
function buildGridTile(src, label) {
|
||
const tile = el("div", "grid-tile");
|
||
const img = el("img");
|
||
img.src = src;
|
||
img.alt = label;
|
||
tile.appendChild(img);
|
||
tile.appendChild(el("div", "tile-label", label));
|
||
return tile;
|
||
}
|
||
|
||
function renderFamilyGrid(data) {
|
||
const fg = data.family_grid;
|
||
if (!fg || fg.error || !fg.input_grid_file) {
|
||
familyGridSection.hidden = true;
|
||
return;
|
||
}
|
||
|
||
familyGridSection.hidden = false;
|
||
familyGridTemplateName.textContent = fg.template;
|
||
|
||
familyGridOverall.innerHTML = "";
|
||
if (fg.overall_area_match_pct != null) {
|
||
const wrap = el("div");
|
||
wrap.appendChild(el("span", "big-pct", `${fg.overall_area_match_pct}%`));
|
||
wrap.appendChild(el("span", "big-pct-label", "Overall area match"));
|
||
familyGridOverall.appendChild(wrap);
|
||
}
|
||
|
||
familyGridRow.innerHTML = "";
|
||
familyGridRow.appendChild(
|
||
buildGridTile(uploadUrl(data.request_id, fg.input_grid_file), "Your upload")
|
||
);
|
||
familyGridRow.appendChild(
|
||
buildGridTile(uploadUrl(data.request_id, fg.template_grid_file), fg.template)
|
||
);
|
||
|
||
familyGridMatches.innerHTML = "";
|
||
(fg.matches || []).forEach(m => {
|
||
const chip = el("div", "family-match");
|
||
chip.appendChild(el("span", "rank", `Region #${m.rank}`));
|
||
|
||
const swatches = el("div", "pair-swatches");
|
||
const inputSwatch = el("span", "swatch");
|
||
inputSwatch.style.background = rgbCss(m.input_rgb);
|
||
const templateSwatch = el("span", "swatch");
|
||
templateSwatch.style.background = rgbCss(m.template_rgb);
|
||
swatches.appendChild(inputSwatch);
|
||
swatches.appendChild(el("span", "pair-arrow", "→"));
|
||
swatches.appendChild(templateSwatch);
|
||
chip.appendChild(swatches);
|
||
|
||
chip.appendChild(el("span", "area-pct", `${m.area_match_pct}% area match`));
|
||
chip.appendChild(el("span", "sub-pct", `${m.input_pct}% vs ${m.template_pct}%`));
|
||
familyGridMatches.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function renderWeighted(data) {
|
||
const weights = data.method_weights || {};
|
||
const labelFor = method => (data.methods[method] && data.methods[method].label) || method;
|
||
|
||
weightedSub.textContent = "Weights — " + Object.entries(weights)
|
||
.map(([method, w]) => `${labelFor(method)} ${Math.round(w * 100)}%`)
|
||
.join(" · ");
|
||
|
||
weightedCard.innerHTML = "";
|
||
const ranked = data.weighted_scores || [];
|
||
if (data.weighted_best && ranked.length) {
|
||
const top = ranked[0];
|
||
|
||
// Input vs. matched template, side by side -- purely a display
|
||
// addition, doesn't touch which template won.
|
||
const thumbs = el("div", "weighted-thumbs");
|
||
const inputImg = el("img");
|
||
inputImg.src = uploadUrl(data.request_id, data.upload_nobg_file);
|
||
inputImg.alt = "Your upload";
|
||
thumbs.appendChild(inputImg);
|
||
thumbs.appendChild(el("span", "arrow", "→"));
|
||
const filename = templateFileByName[data.weighted_best];
|
||
if (filename) {
|
||
const templateImg = el("img");
|
||
templateImg.src = `/template_image/${encodeURIComponent(filename)}`;
|
||
templateImg.alt = data.weighted_best;
|
||
thumbs.appendChild(templateImg);
|
||
}
|
||
weightedCard.appendChild(thumbs);
|
||
|
||
const textWrap = el("div", "weighted-text");
|
||
textWrap.appendChild(el("p", "label", "Weighted final match"));
|
||
textWrap.appendChild(el("h4", null, data.weighted_best));
|
||
textWrap.appendChild(el("p", "weighted-total", `Total weighted score: ${top.weighted_score}`));
|
||
|
||
// Shape/texture are informational only here too -- reusing the
|
||
// scores already computed for their own sections, not recomputed
|
||
// and not fed back into the weighting.
|
||
const shapeBest = ((data.shape_analysis && data.shape_analysis.results) || [])
|
||
.find(r => r.template === data.weighted_best);
|
||
const textureBest = ((data.texture_analysis && data.texture_analysis.results) || [])
|
||
.find(r => r.template === data.weighted_best);
|
||
if (shapeBest || textureBest) {
|
||
const parts = [];
|
||
if (shapeBest) parts.push(`Shape ${shapeBest.match_pct}%`);
|
||
if (textureBest) parts.push(`Texture ${textureBest.match_pct}%`);
|
||
textWrap.appendChild(el("p", "weighted-supporting", parts.join(" · ")));
|
||
}
|
||
|
||
// Normalized confidence: how decisively the winner beat the
|
||
// runner-up, as a % of the winner's own score -- a display-only
|
||
// derivation from the already-final ranked list. It cannot change
|
||
// weighted_best; it only labels how strong/weak that result is.
|
||
const runnerUp = ranked.length > 1 ? ranked[1].weighted_score : 0;
|
||
const marginPct = top.weighted_score > 0
|
||
? Math.max(0, Math.min(100, Math.round(((top.weighted_score - runnerUp) / top.weighted_score) * 100)))
|
||
: 0;
|
||
const verdict = marginPct >= 50 ? "Strong match"
|
||
: marginPct >= 20 ? "Moderate match"
|
||
: "Weak match";
|
||
textWrap.appendChild(el("span", "verdict-pill", `${verdict} — ${marginPct}% confidence`));
|
||
|
||
weightedCard.appendChild(textWrap);
|
||
|
||
const breakdown = el("div", "weighted-breakdown");
|
||
Object.entries(top.breakdown).forEach(([method, contribution]) => {
|
||
const methodResults = (data.methods[method] && data.methods[method].results) || [];
|
||
const rawRow = methodResults.find(r => r.template === data.weighted_best);
|
||
const rawScore = rawRow ? rawRow.score : 0;
|
||
const row = el("div", "row");
|
||
row.appendChild(el("span", null, labelFor(method)));
|
||
row.appendChild(el("span", null,
|
||
`${Math.round((weights[method] || 0) * 100)}% × ${rawScore} = ${contribution}`));
|
||
breakdown.appendChild(row);
|
||
});
|
||
weightedCard.appendChild(breakdown);
|
||
} else {
|
||
weightedCard.appendChild(el("p", null, "No scores to weight yet."));
|
||
}
|
||
|
||
weightedRanked.innerHTML = "";
|
||
const maxScore = Math.max(1, ...ranked.map(r => r.weighted_score));
|
||
ranked.forEach((r, idx) => {
|
||
const row = el("div", `score-bar-row${idx === 0 ? " top" : ""}`);
|
||
row.appendChild(el("span", "name", r.template));
|
||
const track = el("div", "score-bar-track");
|
||
const fill = el("div", "score-bar-fill");
|
||
fill.style.width = `${Math.max(2, (r.weighted_score / maxScore) * 100)}%`;
|
||
track.appendChild(fill);
|
||
row.appendChild(track);
|
||
row.appendChild(el("span", "value", String(r.weighted_score)));
|
||
weightedRanked.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function buildQueryTile(src, label) {
|
||
const tile = el("div", "query-tile");
|
||
const img = el("img");
|
||
img.src = src;
|
||
img.alt = label;
|
||
tile.appendChild(img);
|
||
tile.appendChild(el("div", "tile-label", label));
|
||
return tile;
|
||
}
|
||
|
||
function buildMethodCard(requestId, methodKey, m) {
|
||
const card = el("div", "method-card");
|
||
|
||
const head = el("div", "method-card-head");
|
||
head.appendChild(el("h4", null, m.label));
|
||
head.appendChild(el("span", "time-badge", `${m.time_sec.toFixed(2)}s`));
|
||
card.appendChild(head);
|
||
|
||
if (m.best) {
|
||
const bestRow = el("div", "best-row");
|
||
if (m.best_image_file) {
|
||
const img = el("img");
|
||
img.src = uploadUrl(requestId, m.best_image_file);
|
||
img.alt = m.best.template;
|
||
bestRow.appendChild(img);
|
||
}
|
||
const info = el("div");
|
||
info.appendChild(el("div", "best-name", m.best.template));
|
||
info.appendChild(el("div", "best-score",
|
||
`Score ${m.best.score} · ${m.best.confidence}% inliers`));
|
||
const pill = el("span",
|
||
`confidence-pill ${m.is_confident ? "confident" : "weak"}`,
|
||
m.is_confident ? "Confident match" : "Weak match");
|
||
info.appendChild(pill);
|
||
bestRow.appendChild(info);
|
||
card.appendChild(bestRow);
|
||
} else if (m.error) {
|
||
card.appendChild(el("p", "error-msg", `This method failed: ${m.error}`));
|
||
} else {
|
||
card.appendChild(el("p", "best-score", "No match found."));
|
||
}
|
||
|
||
const bars = el("div", "score-bars");
|
||
const maxScore = Math.max(1, ...m.results.map(r => r.score));
|
||
m.results.forEach((r, idx) => {
|
||
const row = el("div", `score-bar-row${idx === 0 ? " top" : ""}`);
|
||
row.appendChild(el("span", "name", r.template));
|
||
const track = el("div", "score-bar-track");
|
||
const fill = el("div", "score-bar-fill");
|
||
fill.style.width = `${Math.max(2, (r.score / maxScore) * 100)}%`;
|
||
track.appendChild(fill);
|
||
row.appendChild(track);
|
||
row.appendChild(el("span", "value", String(r.score)));
|
||
bars.appendChild(row);
|
||
});
|
||
card.appendChild(bars);
|
||
|
||
return card;
|
||
}
|
||
})();
|