(() => { 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; } })();