 
			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> | |