Add project files
This commit is contained in:
1406
static/css/style.css
Normal file
1406
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
988
static/js/main.js
Normal file
988
static/js/main.js
Normal file
@@ -0,0 +1,988 @@
|
||||
(() => {
|
||||
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;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user