iconclass-predictions / index.html
davanstrien's picture
davanstrien HF Staff
Improve ICONCLASS visualization with Tufte-inspired partial match display
1692046
<!DOCTYPE html>
<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>