|
|
<!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> |
|
|
|
|
|
|
|
|
<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 { |
|
|
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 { |
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
.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 { |
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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-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-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 { |
|
|
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 { |
|
|
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 { |
|
|
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 { |
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
.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; |
|
|
} |
|
|
|
|
|
|
|
|
.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-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 { |
|
|
margin-bottom: 10px; |
|
|
} |
|
|
|
|
|
|
|
|
@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"> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div class="status-box" id="status"> |
|
|
Ready. Select a sample or enter a manifest URL. |
|
|
</div> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div class="results-header"> |
|
|
<h2>Pages</h2> |
|
|
<span class="results-count" id="results-count">0 pages</span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="distribution-sparkline" id="distribution-sparkline"></div> |
|
|
|
|
|
<ul class="results-list" id="results-list"> |
|
|
|
|
|
</ul> |
|
|
</div> |
|
|
</aside> |
|
|
|
|
|
<main class="viewer-container"> |
|
|
<div id="viewer"> |
|
|
<div class="viewer-placeholder"> |
|
|
Load a manifest to view pages |
|
|
</div> |
|
|
</div> |
|
|
</main> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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"> |
|
|
|
|
|
</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]'; |
|
|
|
|
|
|
|
|
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, |
|
|
}; |
|
|
|
|
|
|
|
|
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'), |
|
|
}; |
|
|
|
|
|
|
|
|
async function init() { |
|
|
|
|
|
elements.aboutToggle.addEventListener('click', () => { |
|
|
const isVisible = elements.aboutPanel.style.display !== 'none'; |
|
|
elements.aboutPanel.style.display = isVisible ? 'none' : 'block'; |
|
|
elements.aboutToggle.textContent = isVisible ? 'About' : 'Close'; |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
loadModel(); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function loadManifest() { |
|
|
const url = elements.manifestUrl.value.trim(); |
|
|
if (!url) { |
|
|
updateStatus('Please enter a manifest URL.', 'error'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (state.isRunning) { |
|
|
stopClassification(); |
|
|
} |
|
|
|
|
|
|
|
|
elements.distributionSparkline.innerHTML = ''; |
|
|
elements.distributionSparkline.classList.remove('visible'); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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`; |
|
|
|
|
|
|
|
|
renderCanvasList(); |
|
|
initViewer(); |
|
|
|
|
|
|
|
|
elements.classifyBtn.disabled = false; |
|
|
|
|
|
} catch (error) { |
|
|
updateStatus(`Error loading manifest: ${error.message}`, 'error'); |
|
|
console.error('Manifest loading error:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
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}`; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
function initViewer() { |
|
|
|
|
|
elements.viewer.innerHTML = ''; |
|
|
|
|
|
|
|
|
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); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function navigateToCanvas(index) { |
|
|
if (state.viewer && index >= 0 && index < state.canvases.length) { |
|
|
state.viewer.goToPage(index); |
|
|
highlightResult(index); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function highlightResult(index) { |
|
|
state.currentIndex = index; |
|
|
document.querySelectorAll('.result-item').forEach((item, i) => { |
|
|
item.classList.toggle('active', i === index); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
let objectUrl; |
|
|
if (prefetchPromise) { |
|
|
try { |
|
|
objectUrl = await prefetchPromise; |
|
|
} catch { |
|
|
|
|
|
objectUrl = await fetchImageAsObjectUrl(canvas); |
|
|
} |
|
|
} else { |
|
|
objectUrl = await fetchImageAsObjectUrl(canvas); |
|
|
} |
|
|
|
|
|
|
|
|
if (i + 1 < state.canvases.length) { |
|
|
prefetchPromise = fetchImageAsObjectUrl(state.canvases[i + 1]).catch(() => null); |
|
|
} else { |
|
|
prefetchPromise = null; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
const errorResult = { error: error.message }; |
|
|
state.results.set(canvas.id, errorResult); |
|
|
updateResultItem(canvas.index, { status: 'error', error: error.message }); |
|
|
} |
|
|
|
|
|
processed++; |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
async function classifyCanvas(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'); |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
const response = await fetch(imageUrl, { mode: 'cors' }); |
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`); |
|
|
const blob = await response.blob(); |
|
|
|
|
|
|
|
|
const objectUrl = URL.createObjectURL(blob); |
|
|
|
|
|
try { |
|
|
|
|
|
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; |
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function stopClassification() { |
|
|
if (state.abortController) { |
|
|
state.abortController.abort(); |
|
|
} |
|
|
state.isRunning = false; |
|
|
elements.stopBtn.disabled = true; |
|
|
elements.classifyBtn.disabled = false; |
|
|
updateStatus('Classification stopped.', 'error'); |
|
|
} |
|
|
|
|
|
|
|
|
function renderCanvasList() { |
|
|
elements.resultsList.innerHTML = ''; |
|
|
|
|
|
state.canvases.forEach((canvas, index) => { |
|
|
const item = createResultItem(canvas, index); |
|
|
elements.resultsList.appendChild(item); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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`; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function renderDistributionSparkline() { |
|
|
const threshold = parseInt(elements.threshold.value) / 100; |
|
|
const sparkline = elements.distributionSparkline; |
|
|
|
|
|
|
|
|
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); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
function updateStatus(message, type = '') { |
|
|
elements.status.textContent = message; |
|
|
elements.status.className = 'status-box'; |
|
|
if (type) { |
|
|
elements.status.classList.add(type); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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, |
|
|
}; |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
function viewIllustratedOnly() { |
|
|
if (state.viewingIllustratedOnly) { |
|
|
|
|
|
viewAllPages(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const threshold = parseInt(elements.threshold.value) / 100; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function closeReportModal() { |
|
|
elements.reportModal.classList.remove('visible'); |
|
|
} |
|
|
|
|
|
|
|
|
init(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|