Files
Vase-Matcher/static/js/main.js
2026-08-04 17:09:29 +05:30

989 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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;
}
})();