davanstrien's picture
davanstrien HF Staff
Fix NLW manifest URL (was canvas, not manifest)
4eed7a2
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IIIF Illustration Detector</title>
<!-- OpenSeadragon -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/openseadragon.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
background: #fafafa;
color: #222;
font-size: 14px;
line-height: 1.5;
}
.container {
display: grid;
grid-template-columns: 340px 1fr;
height: 100vh;
}
/* Sidebar - clean, minimal */
.sidebar {
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px 16px 12px;
border-bottom: 1px solid #eee;
}
.sidebar-header h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.sidebar-header p {
margin: 4px 0 0;
font-size: 12px;
color: #666;
}
.sidebar-header a {
color: #555;
text-decoration: none;
}
.sidebar-header a:hover {
color: #000;
text-decoration: underline;
}
/* About toggle and panel */
.about-toggle {
font-size: 11px;
color: #888;
background: none;
border: 1px solid #ddd;
border-radius: 3px;
padding: 2px 8px;
cursor: pointer;
margin-left: 8px;
}
.about-toggle:hover {
color: #333;
border-color: #999;
}
.about-panel {
background: #fafafa;
border-top: 1px solid #eee;
padding: 12px 16px;
font-size: 12px;
line-height: 1.6;
color: #444;
max-height: 300px;
overflow-y: auto;
}
.about-panel h3 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #888;
margin: 0 0 8px 0;
}
.about-panel h3:not(:first-child) {
margin-top: 12px;
}
.about-panel p {
margin: 0 0 8px 0;
}
.about-panel ul {
margin: 0 0 8px 0;
padding-left: 16px;
}
.about-panel li {
margin-bottom: 4px;
}
.about-panel a {
color: #555;
}
.about-panel .privacy-note {
background: #f0f7f0;
padding: 8px;
border-radius: 3px;
margin-top: 8px;
}
/* Report issue modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.visible {
display: flex;
}
.modal {
background: #fff;
border-radius: 6px;
padding: 20px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal h2 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
}
.modal p {
margin: 0 0 12px 0;
font-size: 13px;
color: #555;
}
.modal-details {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
font-family: monospace;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 16px;
max-height: 200px;
overflow-y: auto;
}
.modal-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.modal-buttons button,
.modal-buttons a {
flex: 1;
min-width: 120px;
text-align: center;
text-decoration: none;
}
.btn-copy.copied {
background: #2a6;
border-color: #2a6;
color: #fff;
}
/* Distribution sparkline - shows pattern across book */
.distribution-sparkline {
display: none;
height: 32px;
margin-bottom: 12px;
padding: 4px 0;
border-bottom: 1px solid #eee;
}
.distribution-sparkline.visible {
display: flex;
align-items: flex-end;
gap: 0;
}
.distribution-bar {
flex: 1 1 0;
min-width: 0;
max-width: 8px;
background: #ddd;
border-radius: 1px 1px 0 0;
transition: background 0.2s;
cursor: pointer;
}
.distribution-bar.illustrated {
background: #2a6;
}
.distribution-bar:hover {
opacity: 0.7;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* Form elements - understated */
label {
display: block;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 4px;
color: #888;
}
input[type="url"], input[type="text"], select {
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 13px;
margin-bottom: 10px;
background: #fff;
}
input[type="url"]:focus, select:focus {
outline: none;
border-color: #333;
}
/* Buttons - minimal */
button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: #fff;
color: #333;
transition: all 0.15s;
}
button:hover:not(:disabled) {
border-color: #999;
background: #f5f5f5;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: #333;
color: #fff;
border-color: #333;
}
.btn-primary:hover:not(:disabled) {
background: #444;
border-color: #444;
}
.btn-danger {
color: #c00;
border-color: #c00;
background: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #fff5f5;
}
.btn-group {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
/* Status - subtle inline */
.status-box {
padding: 8px 10px;
font-size: 12px;
margin-bottom: 12px;
border-left: 3px solid #ddd;
background: #fafafa;
color: #555;
}
.status-box.loading {
border-left-color: #f0ad4e;
background: #fffcf5;
}
.status-box.error {
border-left-color: #c00;
background: #fff8f8;
color: #900;
}
.status-box.success {
border-left-color: #2a6;
background: #f6fdf8;
color: #185;
}
/* Progress - thin and clean */
.progress-container {
margin-bottom: 12px;
}
.progress-bar {
width: 100%;
height: 3px;
background: #eee;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #333;
transition: width 0.2s ease;
}
.progress-text {
font-size: 11px;
color: #888;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* Controls - compact */
.controls {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
.threshold-control {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.threshold-control input[type="range"] {
flex: 1;
height: 3px;
-webkit-appearance: none;
background: #ddd;
border-radius: 2px;
}
.threshold-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
background: #333;
border-radius: 50%;
cursor: pointer;
}
.threshold-value {
min-width: 32px;
text-align: right;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.checkbox-control {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #555;
}
.checkbox-control input {
width: 14px;
height: 14px;
}
/* Results header */
.results-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #eee;
}
.results-header h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #888;
}
.results-count {
font-size: 12px;
color: #555;
font-variant-numeric: tabular-nums;
}
/* Results list - compact data table style */
.results-list {
list-style: none;
padding: 0;
margin: 0;
}
.result-item {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 10px;
padding: 6px 4px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background 0.1s;
}
.result-item:hover {
background: #f8f8f8;
}
.result-item.active {
background: #f0f7ff;
}
/* Thumbnail - small, functional */
.thumbnail {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 2px;
background: #f0f0f0;
}
.result-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.result-label {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #222;
}
.result-status {
font-size: 11px;
color: #888;
}
/* Confidence visualization - sparkline style bar */
.result-confidence {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: #666;
min-width: 70px;
justify-content: flex-end;
}
.confidence-bar {
width: 40px;
height: 4px;
background: #eee;
border-radius: 2px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
background: #bbb;
transition: width 0.2s;
}
/* Illustrated items get a subtle green accent */
.result-item.illustrated .confidence-fill {
background: #2a6;
}
.result-item.illustrated .result-confidence {
color: #185;
}
.result-item.processing {
opacity: 0.6;
}
.result-item.processing .result-status {
color: #b80;
}
.result-item.error .result-status {
color: #c00;
}
/* Viewer */
.viewer-container {
background: #111;
position: relative;
}
#viewer {
width: 100%;
height: 100%;
}
.viewer-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 13px;
}
/* Sample manifests */
.sample-manifests {
margin-bottom: 10px;
}
/* Responsive */
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
max-height: 45vh;
}
}
</style>
</head>
<body>
<div class="container">
<aside class="sidebar">
<div class="sidebar-header">
<h1>IIIF Illustration Detector <button class="about-toggle" id="about-toggle">About</button></h1>
<p>Runs entirely in your browser · <a href="https://huggingface.co/small-models-for-glam/historical-illustration-detector" target="_blank" rel="noopener">🤗 Model</a></p>
</div>
<div class="about-panel" id="about-panel" style="display: none;">
<h3>What is this?</h3>
<p>This tool automatically identifies pages containing illustrations, photographs, maps, and diagrams in digitized historical books. It uses a small AI model (2.5MB) that runs directly in your browser.</p>
<h3>What counts as "illustrated"?</h3>
<ul>
<li>Engravings, woodcuts, lithographs</li>
<li>Photographs and artwork</li>
<li>Maps, diagrams, charts</li>
<li>Scientific illustrations</li>
</ul>
<p><strong>Not counted:</strong> Decorative drop caps, ornamental borders, printer's devices.</p>
<h3>What is IIIF?</h3>
<p><a href="https://iiif.io" target="_blank" rel="noopener">IIIF</a> (International Image Interoperability Framework) is a standard used by libraries and museums to share digital images. A "manifest" describes a book's structure and image locations.</p>
<p><strong>Finding manifests:</strong> Look for IIIF logos on library websites, or search "[collection name] IIIF manifest".</p>
<h3>Confidence threshold</h3>
<p>Adjust sensitivity: higher values = stricter classification (fewer false positives, may miss some illustrations). Default 50% works well for most books.</p>
<h3>Accuracy</h3>
<p>~95% accuracy on historical books. <a href="https://huggingface.co/small-models-for-glam/historical-illustration-detector" target="_blank" rel="noopener">See model card</a> for details.</p>
<div class="privacy-note">
<strong>Privacy:</strong> All processing happens in your browser. No images are sent to any server.
</div>
</div>
<div class="sidebar-content">
<!-- Sample manifests -->
<div class="sample-manifests">
<label for="sample-select">Try a sample manifest:</label>
<select id="sample-select">
<option value="">-- Select a sample --</option>
<option value="https://digital.library.villanova.edu/Item/vudl:293339/Manifest">Villanova - Dime Novel (32 pages)</option>
<option value="https://iiif.wellcomecollection.org/presentation/b18035723">Wellcome - Medical illustrations</option>
<option value="https://damsssl.llgc.org.uk/iiif/2.0/1108339/manifest.json">National Library of Wales</option>
<option value="https://iiif.bodleian.ox.ac.uk/iiif/manifest/e32a277e-91e2-4a6d-8ba6-cc4bad230410.json">Bodleian - Medieval Manuscript</option>
<option value="https://iiif.wellcomecollection.org/presentation/b28047345">Wellcome - Insect Transformations (570 pages)</option>
<option value="https://www.e-codices.unifr.ch/metadata/iiif/csg-0406/manifest.json">e-codices - Medieval Manuscript (643 pages)</option>
<option value="https://iiif.archive.org/iiif/Illustratednatuv1Good/manifest.json">Internet Archive - Natural History Goodrich (744 pages)</option>
<option value="https://iiif.archive.org/iiif/illustratednatur01wood/manifest.json">Internet Archive - Natural History Wood (820 pages)</option>
</select>
</div>
<!-- Manifest input -->
<div class="input-group">
<label for="manifest-url">Or enter a IIIF manifest URL:</label>
<input type="url" id="manifest-url" placeholder="https://example.org/manifest.json">
</div>
<!-- Status -->
<div class="status-box" id="status">
Ready. Select a sample or enter a manifest URL.
</div>
<!-- Progress -->
<div class="progress-container" id="progress-container" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
</div>
<div class="progress-text" id="progress-text">0 / 0 pages</div>
</div>
<!-- Action buttons -->
<div class="btn-group">
<button class="btn-primary" id="load-btn">Load Manifest</button>
<button class="btn-primary" id="classify-btn" disabled>Classify</button>
<button class="btn-danger" id="stop-btn" disabled>Stop</button>
</div>
<!-- Controls -->
<div class="controls">
<label>Confidence Threshold:</label>
<div class="threshold-control">
<input type="range" id="threshold" min="0" max="100" value="50">
<span class="threshold-value" id="threshold-value">50%</span>
</div>
<label class="checkbox-control">
<input type="checkbox" id="show-only-illustrated">
Show only illustrated pages
</label>
</div>
<!-- Export & View buttons -->
<div class="btn-group">
<button class="btn-secondary" id="view-illustrated-btn" disabled>View Illustrated Only</button>
<button class="btn-secondary" id="export-btn" disabled>Export Annotations</button>
<button class="btn-secondary" id="report-btn" disabled title="Select a page first, then report if the prediction is wrong">Report Issue</button>
</div>
<!-- Results -->
<div class="results-header">
<h2>Pages</h2>
<span class="results-count" id="results-count">0 pages</span>
</div>
<!-- Distribution sparkline - shows where illustrations are in book -->
<div class="distribution-sparkline" id="distribution-sparkline"></div>
<ul class="results-list" id="results-list">
<!-- Populated dynamically -->
</ul>
</div>
</aside>
<main class="viewer-container">
<div id="viewer">
<div class="viewer-placeholder">
Load a manifest to view pages
</div>
</div>
</main>
</div>
<!-- Report Issue Modal -->
<div class="modal-overlay" id="report-modal">
<div class="modal">
<h2>Report Incorrect Prediction</h2>
<p>Help improve the model by reporting incorrect classifications. Copy the details below and paste them into a new discussion on HuggingFace.</p>
<div class="modal-details" id="report-details">
<!-- Populated by JavaScript -->
</div>
<div class="modal-buttons">
<button class="btn-primary btn-copy" id="copy-details-btn">Copy Details</button>
<a href="https://huggingface.co/small-models-for-glam/historical-illustration-detector/discussions/new"
target="_blank" rel="noopener" class="btn-primary"
style="display: inline-block; padding: 6px 12px; background: #333; color: #fff; border: 1px solid #333; border-radius: 3px;">
Open HF Discussion
</a>
<button class="btn-secondary" id="close-modal-btn">Close</button>
</div>
</div>
</div>
<script type="module">
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/[email protected]';
// Global state
const state = {
manifest: null,
manifestUrl: null,
canvases: [],
results: new Map(),
classifier: null,
viewer: null,
isRunning: false,
abortController: null,
currentIndex: -1,
classifyStartTime: null,
avgTimePerPage: null,
viewingIllustratedOnly: false,
};
// DOM elements
const elements = {
aboutToggle: document.getElementById('about-toggle'),
aboutPanel: document.getElementById('about-panel'),
sampleSelect: document.getElementById('sample-select'),
manifestUrl: document.getElementById('manifest-url'),
status: document.getElementById('status'),
progressContainer: document.getElementById('progress-container'),
progressFill: document.getElementById('progress-fill'),
progressText: document.getElementById('progress-text'),
loadBtn: document.getElementById('load-btn'),
classifyBtn: document.getElementById('classify-btn'),
stopBtn: document.getElementById('stop-btn'),
exportBtn: document.getElementById('export-btn'),
viewIllustratedBtn: document.getElementById('view-illustrated-btn'),
threshold: document.getElementById('threshold'),
thresholdValue: document.getElementById('threshold-value'),
showOnlyIllustrated: document.getElementById('show-only-illustrated'),
resultsCount: document.getElementById('results-count'),
resultsList: document.getElementById('results-list'),
distributionSparkline: document.getElementById('distribution-sparkline'),
viewer: document.getElementById('viewer'),
reportBtn: document.getElementById('report-btn'),
reportModal: document.getElementById('report-modal'),
reportDetails: document.getElementById('report-details'),
copyDetailsBtn: document.getElementById('copy-details-btn'),
closeModalBtn: document.getElementById('close-modal-btn'),
};
// Initialize
async function init() {
// About panel toggle
elements.aboutToggle.addEventListener('click', () => {
const isVisible = elements.aboutPanel.style.display !== 'none';
elements.aboutPanel.style.display = isVisible ? 'none' : 'block';
elements.aboutToggle.textContent = isVisible ? 'About' : 'Close';
});
// Event listeners
elements.sampleSelect.addEventListener('change', (e) => {
if (e.target.value) {
elements.manifestUrl.value = e.target.value;
}
});
elements.loadBtn.addEventListener('click', loadManifest);
elements.classifyBtn.addEventListener('click', classifyAll);
elements.stopBtn.addEventListener('click', stopClassification);
elements.exportBtn.addEventListener('click', exportAnnotations);
elements.viewIllustratedBtn.addEventListener('click', viewIllustratedOnly);
elements.reportBtn.addEventListener('click', showReportModal);
elements.copyDetailsBtn.addEventListener('click', copyReportDetails);
elements.closeModalBtn.addEventListener('click', closeReportModal);
elements.reportModal.addEventListener('click', (e) => {
if (e.target === elements.reportModal) closeReportModal();
});
elements.threshold.addEventListener('input', (e) => {
elements.thresholdValue.textContent = `${e.target.value}%`;
filterResults();
renderDistributionSparkline();
});
elements.showOnlyIllustrated.addEventListener('change', filterResults);
// Load model in background
loadModel();
}
// Load model
async function loadModel() {
updateStatus('Loading AI model...', 'loading');
try {
state.classifier = await pipeline(
'image-classification',
'small-models-for-glam/historical-illustration-detector'
);
updateStatus('Model loaded. Ready to process manifests.');
} catch (error) {
updateStatus(`Error loading model: ${error.message}`, 'error');
console.error('Model loading error:', error);
}
}
// Load manifest
async function loadManifest() {
const url = elements.manifestUrl.value.trim();
if (!url) {
updateStatus('Please enter a manifest URL.', 'error');
return;
}
// Stop any running classification
if (state.isRunning) {
stopClassification();
}
// Clear sparkline from previous manifest
elements.distributionSparkline.innerHTML = '';
elements.distributionSparkline.classList.remove('visible');
// Reset UI state for new manifest
elements.progressContainer.style.display = 'none';
elements.exportBtn.disabled = true;
elements.viewIllustratedBtn.disabled = true;
elements.reportBtn.disabled = true;
state.viewingIllustratedOnly = false;
elements.viewIllustratedBtn.textContent = 'View Illustrated Only';
updateStatus('Fetching manifest...', 'loading');
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const manifest = await response.json();
state.manifestUrl = url;
state.manifest = manifest;
// Detect version and extract canvases
const version = detectManifestVersion(manifest);
state.canvases = extractCanvases(manifest, version);
state.results.clear();
updateStatus(`Loaded ${state.canvases.length} pages (IIIF v${version})`, 'success');
elements.resultsCount.textContent = `${state.canvases.length} pages`;
// Render UI
renderCanvasList();
initViewer();
// Enable classify button
elements.classifyBtn.disabled = false;
} catch (error) {
updateStatus(`Error loading manifest: ${error.message}`, 'error');
console.error('Manifest loading error:', error);
}
}
// Detect IIIF version
function detectManifestVersion(manifest) {
const context = manifest['@context'];
if (Array.isArray(context)) {
if (context.some(c => typeof c === 'string' && c.includes('presentation/3'))) return 3;
} else if (typeof context === 'string' && context.includes('presentation/3')) {
return 3;
}
if (manifest.sequences) return 2;
if (manifest.items) return 3;
return 2;
}
// Extract canvases from manifest
function extractCanvases(manifest, version) {
const canvases = [];
if (version === 2) {
const sequences = manifest.sequences || [];
for (const sequence of sequences) {
const seqCanvases = sequence.canvases || [];
for (let i = 0; i < seqCanvases.length; i++) {
canvases.push(parseCanvasV2(seqCanvases[i], i));
}
}
} else {
const items = manifest.items || [];
for (let i = 0; i < items.length; i++) {
if (items[i].type === 'Canvas') {
canvases.push(parseCanvasV3(items[i], i));
}
}
}
return canvases;
}
// Parse v2 canvas
function parseCanvasV2(canvas, index) {
let imageUrl = null;
let imageServiceUrl = null;
const images = canvas.images || [];
if (images.length > 0) {
const resource = images[0].resource;
if (resource) {
imageUrl = resource['@id'] || resource.id;
const service = resource.service;
if (service) {
imageServiceUrl = service['@id'] || service.id;
}
}
}
return {
id: canvas['@id'] || canvas.id,
label: getLabel(canvas.label, index),
width: canvas.width,
height: canvas.height,
imageUrl,
imageServiceUrl,
thumbnail: getThumbnailUrl(canvas, imageServiceUrl),
index,
};
}
// Parse v3 canvas
function parseCanvasV3(canvas, index) {
let imageUrl = null;
let imageServiceUrl = null;
const annotationPages = canvas.items || [];
for (const page of annotationPages) {
const annotations = page.items || [];
for (const anno of annotations) {
if (anno.motivation === 'painting') {
const body = anno.body;
if (body) {
imageUrl = body.id;
const services = body.service || [];
const service = Array.isArray(services) ? services[0] : services;
if (service) {
imageServiceUrl = service['@id'] || service.id;
}
}
}
}
}
return {
id: canvas.id,
label: getLabel(canvas.label, index),
width: canvas.width,
height: canvas.height,
imageUrl,
imageServiceUrl,
thumbnail: getThumbnailUrl(canvas, imageServiceUrl),
index,
};
}
// Get label from various formats
function getLabel(label, index) {
if (!label) return `Page ${index + 1}`;
if (typeof label === 'string') return label;
if (typeof label === 'object') {
const values = label.en || label.none || Object.values(label)[0];
if (Array.isArray(values)) return values[0];
return values || `Page ${index + 1}`;
}
return `Page ${index + 1}`;
}
// Get thumbnail URL
function getThumbnailUrl(canvas, imageServiceUrl) {
const thumb = canvas.thumbnail;
if (thumb) {
const thumbUrl = Array.isArray(thumb) ? thumb[0] : thumb;
return thumbUrl.id || thumbUrl['@id'] || thumbUrl;
}
if (imageServiceUrl) {
return `${imageServiceUrl}/full/,100/0/default.jpg`;
}
return null;
}
// Initialize OpenSeadragon
function initViewer() {
// Clear placeholder
elements.viewer.innerHTML = '';
// Initialize viewer
if (state.viewer) {
state.viewer.destroy();
}
const tileSources = state.canvases.map(canvas => {
if (canvas.imageServiceUrl) {
return `${canvas.imageServiceUrl}/info.json`;
} else if (canvas.imageUrl) {
return { type: 'image', url: canvas.imageUrl };
}
return null;
}).filter(Boolean);
state.viewer = OpenSeadragon({
id: 'viewer',
prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/',
tileSources: tileSources,
sequenceMode: true,
showNavigator: true,
navigatorPosition: 'BOTTOM_RIGHT',
showSequenceControl: true,
showReferenceStrip: true,
referenceStripScroll: 'horizontal',
});
state.viewer.addHandler('page', (e) => {
highlightResult(e.page);
});
}
// Navigate to canvas
function navigateToCanvas(index) {
if (state.viewer && index >= 0 && index < state.canvases.length) {
state.viewer.goToPage(index);
highlightResult(index);
}
}
// Highlight result item
function highlightResult(index) {
state.currentIndex = index;
document.querySelectorAll('.result-item').forEach((item, i) => {
item.classList.toggle('active', i === index);
});
}
// Classify all canvases with prefetching
async function classifyAll() {
if (!state.classifier || state.canvases.length === 0) {
updateStatus('Model not loaded or no canvases.', 'error');
return;
}
state.isRunning = true;
state.abortController = new AbortController();
state.classifyStartTime = Date.now();
elements.classifyBtn.disabled = true;
elements.stopBtn.disabled = false;
elements.progressContainer.style.display = 'block';
let processed = 0;
const total = state.canvases.length;
let prefetchPromise = null;
updateStatus('Classifying pages...', 'loading');
for (let i = 0; i < state.canvases.length; i++) {
if (state.abortController.signal.aborted) break;
const canvas = state.canvases[i];
updateResultItem(canvas.index, { status: 'processing' });
try {
// Get image: from prefetch if available, otherwise fetch now
let objectUrl;
if (prefetchPromise) {
try {
objectUrl = await prefetchPromise;
} catch {
// Prefetch failed, fetch directly
objectUrl = await fetchImageAsObjectUrl(canvas);
}
} else {
objectUrl = await fetchImageAsObjectUrl(canvas);
}
// Start prefetching next image while we classify this one
if (i + 1 < state.canvases.length) {
prefetchPromise = fetchImageAsObjectUrl(state.canvases[i + 1]).catch(() => null);
} else {
prefetchPromise = null;
}
// Classify current image
const results = await state.classifier(objectUrl);
URL.revokeObjectURL(objectUrl);
const illustrated = results.find(r => r.label === 'illustrated');
const notIllustrated = results.find(r => r.label === 'not-illustrated');
const illustratedScore = illustrated?.score || 0;
const result = {
label: illustratedScore >= 0.5 ? 'illustrated' : 'not-illustrated',
score: illustratedScore >= 0.5 ? illustratedScore : (notIllustrated?.score || 0),
illustratedConfidence: illustratedScore,
raw: results,
};
state.results.set(canvas.id, result);
updateResultItem(canvas.index, { status: 'done', result });
} catch (error) {
prefetchPromise = null; // Reset on error
const errorResult = { error: error.message };
state.results.set(canvas.id, errorResult);
updateResultItem(canvas.index, { status: 'error', error: error.message });
}
processed++;
// Update progress with ETA
const elapsed = Date.now() - state.classifyStartTime;
state.avgTimePerPage = elapsed / processed;
const remaining = total - processed;
const etaMs = remaining * state.avgTimePerPage;
updateProgress(processed, total, etaMs);
}
state.isRunning = false;
elements.classifyBtn.disabled = false;
elements.stopBtn.disabled = true;
elements.exportBtn.disabled = false;
elements.viewIllustratedBtn.disabled = false;
elements.reportBtn.disabled = false;
const illustratedCount = countIllustrated();
updateStatus(`Done! ${illustratedCount} illustrated pages found.`, 'success');
}
// Fetch image and return object URL
async function fetchImageAsObjectUrl(canvas) {
let imageUrl;
if (canvas.imageServiceUrl) {
imageUrl = `${canvas.imageServiceUrl}/full/224,224/0/default.jpg`;
} else {
imageUrl = canvas.imageUrl;
}
if (!imageUrl) throw new Error('No image URL');
const response = await fetch(imageUrl, { mode: 'cors' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
return URL.createObjectURL(blob);
}
// Classify single canvas
async function classifyCanvas(canvas) {
// Get image URL - use IIIF image service if available
let imageUrl;
if (canvas.imageServiceUrl) {
imageUrl = `${canvas.imageServiceUrl}/full/224,224/0/default.jpg`;
} else {
imageUrl = canvas.imageUrl;
}
if (!imageUrl) {
throw new Error('No image URL');
}
try {
// Fetch image
const response = await fetch(imageUrl, { mode: 'cors' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
// Create object URL
const objectUrl = URL.createObjectURL(blob);
try {
// Classify
const results = await state.classifier(objectUrl);
URL.revokeObjectURL(objectUrl);
// Find illustrated score
const illustrated = results.find(r => r.label === 'illustrated');
const notIllustrated = results.find(r => r.label === 'not-illustrated');
const illustratedScore = illustrated?.score || 0;
return {
label: illustratedScore >= 0.5 ? 'illustrated' : 'not-illustrated',
score: illustratedScore >= 0.5 ? illustratedScore : (notIllustrated?.score || 0),
illustratedConfidence: illustratedScore,
raw: results,
};
} catch (e) {
URL.revokeObjectURL(objectUrl);
throw e;
}
} catch (error) {
if (error.message.includes('CORS') || error.name === 'TypeError') {
throw new Error('CORS blocked');
}
throw error;
}
}
// Stop classification
function stopClassification() {
if (state.abortController) {
state.abortController.abort();
}
state.isRunning = false;
elements.stopBtn.disabled = true;
elements.classifyBtn.disabled = false;
updateStatus('Classification stopped.', 'error');
}
// Render canvas list
function renderCanvasList() {
elements.resultsList.innerHTML = '';
state.canvases.forEach((canvas, index) => {
const item = createResultItem(canvas, index);
elements.resultsList.appendChild(item);
});
}
// Create result item element
function createResultItem(canvas, index) {
const item = document.createElement('li');
item.className = 'result-item';
item.dataset.canvasId = canvas.id;
item.dataset.index = index;
item.onclick = () => navigateToCanvas(index);
item.innerHTML = `
<img class="thumbnail" src="${canvas.thumbnail || ''}" alt="" onerror="this.style.display='none'">
<div class="result-info">
<div class="result-label">${canvas.label}</div>
<div class="result-status">Pending</div>
</div>
`;
return item;
}
// Update result item
function updateResultItem(index, { status, result, error }) {
const item = elements.resultsList.children[index];
if (!item) return;
const canvas = state.canvases[index];
const statusEl = item.querySelector('.result-status');
const existingConfidence = item.querySelector('.result-confidence');
if (existingConfidence) existingConfidence.remove();
// Remove old classes
item.classList.remove('illustrated', 'not-illustrated', 'processing', 'error');
if (status === 'processing') {
item.classList.add('processing');
statusEl.textContent = 'Processing...';
} else if (status === 'done' && result) {
const cls = result.label === 'illustrated' ? 'illustrated' : 'not-illustrated';
item.classList.add(cls);
statusEl.textContent = result.label;
// Add confidence with sparkline bar
const pct = (result.illustratedConfidence * 100).toFixed(0);
const confidence = document.createElement('div');
confidence.className = 'result-confidence';
confidence.innerHTML = `
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${pct}%"></div>
</div>
${pct}%
`;
item.appendChild(confidence);
} else if (status === 'error') {
item.classList.add('error');
statusEl.textContent = `Error: ${error}`;
}
filterResults();
updateResultsCount();
}
// Filter results based on threshold and checkbox
function filterResults() {
const showOnlyIllustrated = elements.showOnlyIllustrated.checked;
const threshold = parseInt(elements.threshold.value) / 100;
Array.from(elements.resultsList.children).forEach((item, index) => {
const canvas = state.canvases[index];
const result = state.results.get(canvas.id);
let show = true;
if (showOnlyIllustrated && result) {
show = result.illustratedConfidence >= threshold;
}
item.style.display = show ? '' : 'none';
});
updateResultsCount();
}
// Count illustrated
function countIllustrated() {
const threshold = parseInt(elements.threshold.value) / 100;
let count = 0;
for (const result of state.results.values()) {
if (!result.error && result.illustratedConfidence >= threshold) {
count++;
}
}
return count;
}
// Update results count
function updateResultsCount() {
const total = state.canvases.length;
const illustrated = countIllustrated();
const processed = state.results.size;
if (processed > 0) {
elements.resultsCount.textContent = `${illustrated} illustrated / ${total} total`;
renderDistributionSparkline();
} else {
elements.resultsCount.textContent = `${total} pages`;
}
}
// Render distribution sparkline - shows pattern of illustrations across book
function renderDistributionSparkline() {
const threshold = parseInt(elements.threshold.value) / 100;
const sparkline = elements.distributionSparkline;
// Only show if we have results
if (state.results.size === 0) {
sparkline.classList.remove('visible');
return;
}
sparkline.innerHTML = '';
sparkline.classList.add('visible');
state.canvases.forEach((canvas, index) => {
const result = state.results.get(canvas.id);
const bar = document.createElement('div');
bar.className = 'distribution-bar';
if (result && !result.error) {
const confidence = result.illustratedConfidence;
bar.style.height = `${Math.max(confidence * 100, 4)}%`;
if (confidence >= threshold) {
bar.classList.add('illustrated');
}
} else {
bar.style.height = '4%';
}
bar.title = `${canvas.label}: ${result ? Math.round(result.illustratedConfidence * 100) : '?'}%`;
bar.onclick = () => navigateToCanvas(index);
sparkline.appendChild(bar);
});
}
// Update progress
function updateProgress(current, total, etaMs = null) {
const percent = total > 0 ? (current / total) * 100 : 0;
elements.progressFill.style.width = `${percent}%`;
let text = `${current} / ${total} pages`;
if (etaMs !== null && etaMs > 0) {
const etaSec = Math.ceil(etaMs / 1000);
if (etaSec < 60) {
text += ` (~${etaSec}s remaining)`;
} else {
const mins = Math.floor(etaSec / 60);
const secs = etaSec % 60;
text += ` (~${mins}m ${secs}s remaining)`;
}
}
elements.progressText.textContent = text;
}
// Update status
function updateStatus(message, type = '') {
elements.status.textContent = message;
elements.status.className = 'status-box';
if (type) {
elements.status.classList.add(type);
}
}
// Export annotations (IIIF format)
function exportAnnotations() {
const threshold = parseInt(elements.threshold.value) / 100;
const annotations = [];
let annoIndex = 0;
for (const canvas of state.canvases) {
const result = state.results.get(canvas.id);
if (!result || result.error) continue;
// Only include illustrated pages above threshold
if (result.illustratedConfidence < threshold) continue;
annoIndex++;
annotations.push({
id: `${state.manifestUrl}#annotation-${annoIndex}`,
type: 'Annotation',
motivation: 'tagging',
body: {
type: 'TextualBody',
value: 'illustrated',
format: 'text/plain',
},
target: canvas.id,
});
}
const annotationPage = {
'@context': 'http://iiif.io/api/presentation/3/context.json',
id: `${state.manifestUrl}#annotation-page`,
type: 'AnnotationPage',
label: {
en: ['Illustration Classification Results']
},
summary: {
en: [`${annotations.length} pages classified as illustrated (threshold: ${Math.round(threshold * 100)}%)`]
},
items: annotations,
};
// Download
const json = JSON.stringify(annotationPage, null, 2);
const blob = new Blob([json], { type: 'application/ld+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'illustration-annotations.json';
a.click();
URL.revokeObjectURL(url);
updateStatus(`Exported ${annotations.length} annotations.`, 'success');
}
// Toggle between viewing all pages and only illustrated pages
function viewIllustratedOnly() {
if (state.viewingIllustratedOnly) {
// Reset to view all pages
viewAllPages();
return;
}
const threshold = parseInt(elements.threshold.value) / 100;
// Get illustrated canvases
const illustratedCanvases = state.canvases.filter(canvas => {
const result = state.results.get(canvas.id);
return result && !result.error && result.illustratedConfidence >= threshold;
});
if (illustratedCanvases.length === 0) {
updateStatus('No illustrated pages found above threshold.', 'error');
return;
}
// Build tile sources for only illustrated pages
const tileSources = illustratedCanvases.map(canvas => {
if (canvas.imageServiceUrl) {
return `${canvas.imageServiceUrl}/info.json`;
} else if (canvas.imageUrl) {
return { type: 'image', url: canvas.imageUrl };
}
return null;
}).filter(Boolean);
// Reinitialize viewer with only illustrated pages
if (state.viewer) {
state.viewer.destroy();
}
elements.viewer.innerHTML = '';
state.viewer = OpenSeadragon({
id: 'viewer',
prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/',
tileSources: tileSources,
sequenceMode: true,
showNavigator: true,
navigatorPosition: 'BOTTOM_RIGHT',
showSequenceControl: true,
showReferenceStrip: true,
referenceStripScroll: 'horizontal',
});
state.viewingIllustratedOnly = true;
elements.viewIllustratedBtn.textContent = 'View All Pages';
updateStatus(`Viewing ${illustratedCanvases.length} illustrated pages.`, 'success');
}
// Reset viewer to show all pages
function viewAllPages() {
if (state.viewer) {
state.viewer.destroy();
}
elements.viewer.innerHTML = '';
const tileSources = state.canvases.map(canvas => {
if (canvas.imageServiceUrl) {
return `${canvas.imageServiceUrl}/info.json`;
} else if (canvas.imageUrl) {
return { type: 'image', url: canvas.imageUrl };
}
return null;
}).filter(Boolean);
state.viewer = OpenSeadragon({
id: 'viewer',
prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/',
tileSources: tileSources,
sequenceMode: true,
showNavigator: true,
navigatorPosition: 'BOTTOM_RIGHT',
showSequenceControl: true,
showReferenceStrip: true,
referenceStripScroll: 'horizontal',
});
state.viewer.addHandler('page', (e) => {
highlightResult(e.page);
});
state.viewingIllustratedOnly = false;
elements.viewIllustratedBtn.textContent = 'View Illustrated Only';
updateStatus(`Viewing all ${state.canvases.length} pages.`, 'success');
}
// Show report modal for current page
function showReportModal() {
const canvas = state.canvases[state.currentIndex];
if (!canvas) {
updateStatus('Click a page in the list below to select it first.', 'error');
return;
}
const result = state.results.get(canvas.id);
const confidence = result ? (result.illustratedConfidence * 100).toFixed(1) : 'N/A';
const prediction = result ? result.label : 'not classified';
const correctLabel = prediction === 'illustrated' ? 'not-illustrated' : 'illustrated';
// Build the report details
const details = `## Incorrect Prediction Report
**Manifest:** ${state.manifestUrl}
**Page:** ${canvas.label} (index ${canvas.index})
**Image:** ${canvas.imageServiceUrl || canvas.imageUrl}
**Predicted:** ${prediction} (${confidence}% confidence)
**Should be:** ${correctLabel}
**Additional context:** [Optional - describe what's on the page]`;
elements.reportDetails.textContent = details;
elements.reportModal.classList.add('visible');
elements.copyDetailsBtn.textContent = 'Copy Details';
elements.copyDetailsBtn.classList.remove('copied');
}
// Copy report details to clipboard
async function copyReportDetails() {
try {
await navigator.clipboard.writeText(elements.reportDetails.textContent);
elements.copyDetailsBtn.textContent = 'Copied!';
elements.copyDetailsBtn.classList.add('copied');
setTimeout(() => {
elements.copyDetailsBtn.textContent = 'Copy Details';
elements.copyDetailsBtn.classList.remove('copied');
}, 2000);
} catch (err) {
updateStatus('Failed to copy to clipboard', 'error');
}
}
// Close report modal
function closeReportModal() {
elements.reportModal.classList.remove('visible');
}
// Start
init();
</script>
</body>
</html>