
davanstrien
HF Staff
Improve ICONCLASS visualization with Tufte-inspired partial match display
1692046
<html> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>ICONCLASS Model Evaluation - davanstrien/iconclass-vlm</title> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | |
sans-serif; | |
background: white; | |
padding: 20px; | |
line-height: 1.5; | |
color: #333; | |
font-size: 14px; | |
} | |
.header { | |
max-width: 800px; | |
margin: 0 auto 30px; | |
padding: 0; | |
border-bottom: 1px solid #e5e5e5; | |
padding-bottom: 20px; | |
} | |
h1 { | |
font-size: 20px; | |
font-weight: 600; | |
color: #333; | |
margin: 0 0 8px 0; | |
} | |
.subtitle { | |
font-size: 14px; | |
color: #666; | |
margin: 0 0 12px 0; | |
line-height: 1.4; | |
} | |
.subtitle a { | |
color: #0066cc; | |
text-decoration: none; | |
} | |
.subtitle a:hover { | |
text-decoration: underline; | |
} | |
.description { | |
font-size: 13px; | |
color: #666; | |
margin: 0 0 8px 0; | |
line-height: 1.5; | |
} | |
.stats { | |
font-size: 11px; | |
color: #999; | |
margin: 0; | |
} | |
.gallery { | |
max-width: 800px; | |
margin: 0 auto; | |
} | |
.card { | |
background: white; | |
border: 1px solid #e5e5e5; | |
border-radius: 2px; | |
overflow: hidden; | |
margin-bottom: 20px; | |
} | |
.card img { | |
width: 100%; | |
height: auto; | |
max-height: 500px; | |
object-fit: contain; | |
display: block; | |
border-bottom: 1px solid #e5e5e5; | |
background: #fafafa; | |
} | |
.card-content { | |
padding: 15px; | |
} | |
.raw-toggle { | |
font-size: 11px; | |
color: #999; | |
cursor: pointer; | |
margin-bottom: 12px; | |
user-select: none; | |
font-family: "SF Mono", Monaco, "Cascadia Code", monospace; | |
} | |
.raw-toggle:hover { | |
color: #666; | |
} | |
.raw-prediction { | |
display: none; | |
background: #fafafa; | |
padding: 8px; | |
border: 1px solid #e5e5e5; | |
border-radius: 2px; | |
font-family: "SF Mono", Monaco, "Cascadia Code", monospace; | |
font-size: 11px; | |
color: #666; | |
margin-bottom: 15px; | |
word-break: break-all; | |
line-height: 1.4; | |
} | |
.raw-prediction.visible { | |
display: block; | |
} | |
.comparison { | |
display: grid; | |
grid-template-columns: 1fr 1fr; | |
gap: 20px; | |
margin-top: 0; | |
} | |
.column { | |
font-size: 13px; | |
} | |
.column-title { | |
font-weight: 400; | |
margin-bottom: 8px; | |
color: #999; | |
font-size: 10px; | |
letter-spacing: 0.5px; | |
text-transform: uppercase; | |
} | |
/* Clean, minimal label styles following Tufte principles */ | |
/* Match statistics */ | |
.match-stats { | |
font-size: 11px; | |
color: #999; | |
margin-top: 10px; | |
padding-top: 10px; | |
border-top: 1px solid #f0f0f0; | |
} | |
/* Tufte-inspired styles for clear match visualization */ | |
.match-groups { | |
margin-top: 12px; | |
} | |
.match-group { | |
margin-bottom: 20px; | |
} | |
.group-header { | |
font-size: 10px; | |
color: #999; | |
text-transform: uppercase; | |
letter-spacing: 0.5px; | |
margin-bottom: 6px; | |
padding-bottom: 3px; | |
border-bottom: 1px solid #f0f0f0; | |
} | |
.match-pair { | |
display: flex; | |
align-items: baseline; | |
margin: 4px 0; | |
position: relative; | |
} | |
.match-connection { | |
position: absolute; | |
left: 85px; | |
width: 1px; | |
height: 100%; | |
background: #e0e0e0; | |
} | |
.iconclass-code { | |
font-family: "SF Mono", Monaco, "Cascadia Code", monospace; | |
font-size: 11px; | |
letter-spacing: 0.3px; | |
} | |
.code-part { | |
display: inline-block; | |
} | |
.code-matched { | |
color: #000; | |
font-weight: 600; | |
} | |
.code-unmatched { | |
color: #ccc; | |
} | |
.match-depth-bar { | |
display: inline-block; | |
width: 40px; | |
height: 10px; | |
margin: 0 8px; | |
background: #f5f5f5; | |
position: relative; | |
border-radius: 1px; | |
} | |
.match-depth-fill { | |
position: absolute; | |
left: 0; | |
top: 0; | |
height: 100%; | |
background: #666; | |
border-radius: 1px; | |
} | |
.prediction-side, .gt-side { | |
flex: 1; | |
display: flex; | |
align-items: baseline; | |
position: relative; | |
} | |
.side-label { | |
position: absolute; | |
top: -14px; | |
left: 0; | |
font-size: 9px; | |
color: #999; | |
text-transform: uppercase; | |
letter-spacing: 0.5px; | |
} | |
.code-column { | |
width: 80px; | |
flex-shrink: 0; | |
margin-right: 12px; | |
} | |
.description-column { | |
flex: 1; | |
font-size: 12px; | |
color: #666; | |
line-height: 1.4; | |
} | |
.unmatched-item { | |
display: flex; | |
align-items: baseline; | |
margin: 3px 0; | |
opacity: 0.7; | |
} | |
.match-symbol { | |
font-size: 10px; | |
color: #999; | |
margin: 0 6px; | |
font-weight: normal; | |
} | |
.controls { | |
text-align: center; | |
margin: 30px 0; | |
padding-top: 20px; | |
} | |
.load-more { | |
color: #0066cc; | |
text-decoration: none; | |
font-size: 13px; | |
cursor: pointer; | |
background: none; | |
border: none; | |
padding: 0; | |
font-family: inherit; | |
} | |
.load-more:hover { | |
text-decoration: underline; | |
} | |
.load-more:disabled { | |
color: #999; | |
cursor: default; | |
text-decoration: none; | |
} | |
.loading { | |
text-align: center; | |
padding: 10px; | |
color: #999; | |
font-size: 12px; | |
} | |
.loading.hidden { | |
display: none; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="header"> | |
<h1>ICONCLASS Model Evaluation</h1> | |
<div class="subtitle"> | |
Comparing predictions from <a href="https://huggingface.co/davanstrien/iconclass-vlm" target="_blank">davanstrien/iconclass-vlm</a> against ground truth labels | |
</div> | |
<div class="description"> | |
A vision-language model fine-tuned on Qwen2.5-VL-3B for classifying art and cultural heritage images using ICONCLASS notation — a hierarchical classification system for art and iconography. | |
</div> | |
<div class="stats"> | |
Showing <span id="loadedCount">0</span> of <span id="totalCount">-</span> test images | |
</div> | |
</div> | |
<div id="gallery" class="gallery"></div> | |
<div class="loading hidden" id="loading">Loading...</div> | |
<div class="controls"> | |
<button id="loadMore" class="load-more">Load more images</button> | |
</div> | |
<script> | |
// Configuration | |
const DATASET = "davanstrien/iconclass-sft-predictions"; | |
const CONFIG = "default"; | |
const SPLIT = "test"; | |
const PAGE_SIZE = 10; | |
// State | |
let currentOffset = 0; | |
let totalRows = null; | |
let isLoading = false; | |
// Extract Iconclass code from a full label (e.g., "71H713 Bathsheba alone" -> "71H713") | |
function extractIconclassCode(label) { | |
if (!label || label === "Not a valid iconclass label") return null; | |
// Match alphanumeric codes, optionally followed by parentheses content | |
const match = label.match(/^([A-Z0-9]+(?:\([^)]*\))?)/i); | |
return match ? match[1] : null; | |
} | |
// Calculate the depth of match between two Iconclass codes | |
function calculateMatchDepth(code1, code2) { | |
if (!code1 || !code2) return 0; | |
let matchLength = 0; | |
const minLength = Math.min(code1.length, code2.length); | |
for (let i = 0; i < minLength; i++) { | |
if (code1[i] === code2[i]) { | |
matchLength++; | |
} else { | |
break; | |
} | |
} | |
return { | |
matchLength, | |
code1Length: code1.length, | |
code2Length: code2.length, | |
isExact: code1 === code2, | |
isPartial: matchLength > 0 && matchLength < Math.max(code1.length, code2.length), | |
matchRatio: matchLength / Math.max(code1.length, code2.length) | |
}; | |
} | |
// Find best matching ground truth for a prediction | |
function findBestMatch(predLabel, groundTruthLabels) { | |
const predCode = extractIconclassCode(predLabel); | |
if (!predCode) return null; | |
let bestMatch = null; | |
let bestMatchDepth = 0; | |
for (const gtLabel of groundTruthLabels) { | |
const gtCode = extractIconclassCode(gtLabel); | |
if (!gtCode) continue; | |
const matchInfo = calculateMatchDepth(predCode, gtCode); | |
if (matchInfo.matchLength > bestMatchDepth) { | |
bestMatchDepth = matchInfo.matchLength; | |
bestMatch = { | |
gtLabel, | |
gtCode, | |
predCode, | |
...matchInfo | |
}; | |
} | |
} | |
return bestMatch; | |
} | |
// Format code with matched/unmatched portions highlighted | |
function formatCodeWithMatch(code, matchLength) { | |
if (!code) return ''; | |
const matched = code.substring(0, matchLength); | |
const unmatched = code.substring(matchLength); | |
return `<span class="code-matched">${matched}</span><span class="code-unmatched">${unmatched}</span>`; | |
} | |
// Get match indicator symbol | |
function getMatchSymbol(matchInfo) { | |
if (!matchInfo) return '≠'; | |
if (matchInfo.isExact) return '='; | |
if (matchInfo.matchRatio > 0.5) return '≈'; | |
if (matchInfo.matchRatio > 0) return '∼'; | |
return '≠'; | |
} | |
async function loadDatasetPage() { | |
if (isLoading) return; | |
isLoading = true; | |
const loadingDiv = document.getElementById("loading"); | |
loadingDiv.classList.remove("hidden"); | |
try { | |
const response = await fetch( | |
`https://datasets-server.huggingface.co/rows?dataset=${DATASET}&config=${CONFIG}&split=${SPLIT}&offset=${currentOffset}&length=${PAGE_SIZE}` | |
); | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`); | |
} | |
const data = await response.json(); | |
// Update stats | |
if (data.num_rows_total) { | |
totalRows = data.num_rows_total; | |
document.getElementById("totalCount").textContent = totalRows; | |
} | |
// Display rows | |
displayRows(data.rows); | |
// Update counter | |
currentOffset += data.rows.length; | |
document.getElementById("loadedCount").textContent = currentOffset; | |
// Update button | |
const loadMoreBtn = document.getElementById("loadMore"); | |
if (currentOffset >= totalRows) { | |
loadMoreBtn.disabled = true; | |
loadMoreBtn.textContent = "All Images Loaded"; | |
} else { | |
loadMoreBtn.textContent = `Load more images (${ | |
totalRows - currentOffset | |
} remaining)`; | |
} | |
} catch (error) { | |
console.error("Error:", error); | |
} finally { | |
isLoading = false; | |
loadingDiv.classList.add("hidden"); | |
} | |
} | |
function displayRows(rows) { | |
const gallery = document.getElementById("gallery"); | |
rows.forEach((item) => { | |
const row = item.row; | |
// Create card | |
const card = document.createElement("div"); | |
card.className = "card"; | |
// Add image | |
if (row.images && row.images.length > 0) { | |
const img = document.createElement("img"); | |
img.src = row.images[0].src; | |
img.loading = "lazy"; | |
card.appendChild(img); | |
} | |
// Create content | |
const content = document.createElement("div"); | |
content.className = "card-content"; | |
// Show raw prediction (collapsible) | |
if (row["iconclass-prediction"]) { | |
const toggleDiv = document.createElement("div"); | |
toggleDiv.className = "raw-toggle"; | |
toggleDiv.textContent = "+ Show raw prediction"; | |
const rawDiv = document.createElement("div"); | |
rawDiv.className = "raw-prediction"; | |
rawDiv.textContent = row["iconclass-prediction"]; | |
toggleDiv.addEventListener("click", () => { | |
if (rawDiv.classList.contains("visible")) { | |
rawDiv.classList.remove("visible"); | |
toggleDiv.textContent = "+ Show raw prediction"; | |
} else { | |
rawDiv.classList.add("visible"); | |
toggleDiv.textContent = "− Hide raw prediction"; | |
} | |
}); | |
content.appendChild(toggleDiv); | |
content.appendChild(rawDiv); | |
} | |
// Parse predictions and ground truth | |
const predictions = row["iconclass-predictions-parsed"] || []; | |
const groundTruth = row["iconclass-gt-parsed"] || []; | |
// Check for invalid labels | |
const invalidPredictions = predictions.map((pred) => { | |
return ( | |
pred.toLowerCase().includes("not a valid") || | |
pred.toLowerCase().includes("invalid") | |
); | |
}); | |
// Build match data structure | |
const exactMatches = []; | |
const partialMatches = []; | |
const unmatchedPredictions = []; | |
const unmatchedGroundTruth = new Set(groundTruth); | |
// Find all matches | |
predictions.forEach((pred, idx) => { | |
if (invalidPredictions[idx]) { | |
unmatchedPredictions.push({ prediction: pred, invalid: true }); | |
} else { | |
const bestMatch = findBestMatch(pred, groundTruth); | |
if (bestMatch && bestMatch.matchLength > 0) { | |
unmatchedGroundTruth.delete(bestMatch.gtLabel); | |
if (bestMatch.isExact) { | |
exactMatches.push({ prediction: pred, groundTruth: bestMatch.gtLabel, match: bestMatch }); | |
} else { | |
partialMatches.push({ prediction: pred, groundTruth: bestMatch.gtLabel, match: bestMatch }); | |
} | |
} else { | |
unmatchedPredictions.push({ prediction: pred }); | |
} | |
} | |
}); | |
// Sort partial matches by match quality | |
partialMatches.sort((a, b) => b.match.matchRatio - a.match.matchRatio); | |
// Create new visualization | |
const matchGroups = document.createElement("div"); | |
matchGroups.className = "match-groups"; | |
// Helper function to extract description | |
function getDescription(label) { | |
const idx = label.indexOf(' '); | |
return idx > -1 ? label.substring(idx + 1) : ''; | |
} | |
// Helper function to create match depth bar | |
function createMatchBar(match) { | |
const bar = document.createElement("div"); | |
bar.className = "match-depth-bar"; | |
const fill = document.createElement("div"); | |
fill.className = "match-depth-fill"; | |
fill.style.width = `${Math.round(match.matchRatio * 100)}%`; | |
bar.appendChild(fill); | |
return bar; | |
} | |
// Exact matches group | |
if (exactMatches.length > 0) { | |
const group = document.createElement("div"); | |
group.className = "match-group"; | |
const header = document.createElement("div"); | |
header.className = "group-header"; | |
header.textContent = `Exact Matches (${exactMatches.length})`; | |
group.appendChild(header); | |
// Add labels for first match only | |
let isFirst = true; | |
exactMatches.forEach(item => { | |
const pair = document.createElement("div"); | |
pair.className = "match-pair"; | |
if (isFirst) { | |
pair.style.marginTop = "16px"; // Space for labels | |
} | |
const predSide = document.createElement("div"); | |
predSide.className = "prediction-side"; | |
if (isFirst) { | |
const predLabel = document.createElement("span"); | |
predLabel.className = "side-label"; | |
predLabel.textContent = "PREDICTION"; | |
predSide.appendChild(predLabel); | |
} | |
const predCode = document.createElement("span"); | |
predCode.className = "code-column iconclass-code"; | |
predCode.innerHTML = `<span class="code-matched">${extractIconclassCode(item.prediction)}</span>`; | |
const predDesc = document.createElement("span"); | |
predDesc.className = "description-column"; | |
predDesc.textContent = getDescription(item.prediction); | |
predSide.appendChild(predCode); | |
predSide.appendChild(predDesc); | |
const symbol = document.createElement("span"); | |
symbol.className = "match-symbol"; | |
symbol.textContent = "="; | |
const gtSide = document.createElement("div"); | |
gtSide.className = "gt-side"; | |
if (isFirst) { | |
const gtLabel = document.createElement("span"); | |
gtLabel.className = "side-label"; | |
gtLabel.textContent = "GROUND TRUTH"; | |
gtSide.appendChild(gtLabel); | |
} | |
const gtCode = document.createElement("span"); | |
gtCode.className = "code-column iconclass-code"; | |
gtCode.innerHTML = `<span class="code-matched">${extractIconclassCode(item.groundTruth)}</span>`; | |
const gtDesc = document.createElement("span"); | |
gtDesc.className = "description-column"; | |
gtDesc.textContent = getDescription(item.groundTruth); | |
gtSide.appendChild(gtCode); | |
gtSide.appendChild(gtDesc); | |
pair.appendChild(predSide); | |
pair.appendChild(symbol); | |
pair.appendChild(gtSide); | |
group.appendChild(pair); | |
isFirst = false; | |
}); | |
matchGroups.appendChild(group); | |
} | |
// Partial matches group | |
if (partialMatches.length > 0) { | |
const group = document.createElement("div"); | |
group.className = "match-group"; | |
const header = document.createElement("div"); | |
header.className = "group-header"; | |
header.textContent = `Partial Matches (${partialMatches.length})`; | |
group.appendChild(header); | |
// Add labels for first match only | |
let isFirst = true; | |
partialMatches.forEach(item => { | |
const pair = document.createElement("div"); | |
pair.className = "match-pair"; | |
if (isFirst) { | |
pair.style.marginTop = "16px"; // Space for labels | |
} | |
const predSide = document.createElement("div"); | |
predSide.className = "prediction-side"; | |
if (isFirst) { | |
const predLabel = document.createElement("span"); | |
predLabel.className = "side-label"; | |
predLabel.textContent = "PREDICTION"; | |
predSide.appendChild(predLabel); | |
} | |
const predCode = document.createElement("span"); | |
predCode.className = "code-column iconclass-code"; | |
predCode.innerHTML = formatCodeWithMatch(item.match.predCode, item.match.matchLength); | |
const predDesc = document.createElement("span"); | |
predDesc.className = "description-column"; | |
predDesc.textContent = getDescription(item.prediction); | |
predSide.appendChild(predCode); | |
predSide.appendChild(predDesc); | |
const matchBar = createMatchBar(item.match); | |
const gtSide = document.createElement("div"); | |
gtSide.className = "gt-side"; | |
if (isFirst) { | |
const gtLabel = document.createElement("span"); | |
gtLabel.className = "side-label"; | |
gtLabel.textContent = "GROUND TRUTH"; | |
gtSide.appendChild(gtLabel); | |
} | |
const gtCode = document.createElement("span"); | |
gtCode.className = "code-column iconclass-code"; | |
gtCode.innerHTML = formatCodeWithMatch(item.match.gtCode, item.match.matchLength); | |
const gtDesc = document.createElement("span"); | |
gtDesc.className = "description-column"; | |
gtDesc.textContent = getDescription(item.groundTruth); | |
gtSide.appendChild(gtCode); | |
gtSide.appendChild(gtDesc); | |
pair.appendChild(predSide); | |
pair.appendChild(matchBar); | |
pair.appendChild(gtSide); | |
group.appendChild(pair); | |
isFirst = false; | |
}); | |
matchGroups.appendChild(group); | |
} | |
// Unmatched items group | |
if (unmatchedPredictions.length > 0 || unmatchedGroundTruth.size > 0) { | |
const group = document.createElement("div"); | |
group.className = "match-group"; | |
const header = document.createElement("div"); | |
header.className = "group-header"; | |
header.textContent = `No Matches`; | |
group.appendChild(header); | |
// Unmatched predictions | |
unmatchedPredictions.forEach(item => { | |
const div = document.createElement("div"); | |
div.className = "unmatched-item"; | |
const label = document.createElement("span"); | |
label.style.marginRight = "20px"; | |
label.innerHTML = `<span class="iconclass-code" style="color: #999">P:</span> `; | |
const code = extractIconclassCode(item.prediction); | |
if (code) { | |
label.innerHTML += `<span class="iconclass-code code-unmatched">${code}</span> `; | |
} | |
label.innerHTML += `<span class="description-column">${getDescription(item.prediction)}</span>`; | |
div.appendChild(label); | |
group.appendChild(div); | |
}); | |
// Unmatched ground truth | |
unmatchedGroundTruth.forEach(gt => { | |
const div = document.createElement("div"); | |
div.className = "unmatched-item"; | |
const label = document.createElement("span"); | |
label.innerHTML = `<span class="iconclass-code" style="color: #999">G:</span> `; | |
const code = extractIconclassCode(gt); | |
if (code) { | |
label.innerHTML += `<span class="iconclass-code" style="color: #666">${code}</span> `; | |
} | |
label.innerHTML += `<span class="description-column">${getDescription(gt)}</span>`; | |
div.appendChild(label); | |
group.appendChild(div); | |
}); | |
matchGroups.appendChild(group); | |
} | |
content.appendChild(matchGroups); | |
// Add compact match statistics | |
const validPredictions = predictions.filter( | |
(_, idx) => !invalidPredictions[idx] | |
); | |
const statsDiv = document.createElement("div"); | |
statsDiv.className = "match-stats"; | |
if (validPredictions.length > 0) { | |
const totalMatches = exactMatches.length + partialMatches.length; | |
const statsParts = []; | |
if (exactMatches.length > 0) { | |
statsParts.push(`${exactMatches.length} exact`); | |
} | |
if (partialMatches.length > 0) { | |
statsParts.push(`${partialMatches.length} partial`); | |
} | |
if (unmatchedPredictions.length > 0) { | |
statsParts.push(`${unmatchedPredictions.length} unmatched`); | |
} | |
statsDiv.textContent = statsParts.join(' • '); | |
} else { | |
statsDiv.textContent = 'No valid predictions'; | |
} | |
content.appendChild(statsDiv); | |
card.appendChild(content); | |
gallery.appendChild(card); | |
}); | |
} | |
// Event listeners | |
document | |
.getElementById("loadMore") | |
.addEventListener("click", loadDatasetPage); | |
// Infinite scroll | |
window.addEventListener("scroll", () => { | |
if ( | |
window.innerHeight + window.scrollY >= | |
document.body.offsetHeight - 100 | |
) { | |
if (!isLoading && currentOffset < totalRows) { | |
loadDatasetPage(); | |
} | |
} | |
}); | |
// Load first page | |
loadDatasetPage(); | |
</script> | |
</body> | |
</html> | |