| | <!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/transformers@3.1.2'; |
| | |
| | |
| | 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; |
| | } |
| | |
| | |
| | const tileSourceForCanvas = async (canvas) => { |
| | if (!canvas.imageServiceUrl) { |
| | return canvas.imageUrl ? { type: 'image', url: canvas.imageUrl } : null; |
| | } |
| | |
| | const info = await fetchImageInfo(canvas.imageServiceUrl); |
| | if (info && isLevel0(info)) { |
| | const largest = info.sizes?.length |
| | ? [...info.sizes].sort((a, b) => b.width * b.height - a.width * a.height)[0] |
| | : null; |
| | const url = largest |
| | ? `${canvas.imageServiceUrl}/full/${largest.width},${largest.height}/0/default.jpg` |
| | : canvas.imageUrl; |
| | return url ? { type: 'image', url } : null; |
| | } |
| | |
| | return `${canvas.imageServiceUrl}/info.json`; |
| | }; |
| | |
| | |
| | const buildTileSources = (canvases) => |
| | Promise.all(canvases.map(tileSourceForCanvas)).then(s => s.filter(Boolean)); |
| | |
| | |
| | const setupViewer = (tileSources, { onPage } = {}) => { |
| | 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, |
| | sequenceMode: true, |
| | showNavigator: true, |
| | navigatorPosition: 'BOTTOM_RIGHT', |
| | showSequenceControl: true, |
| | showReferenceStrip: true, |
| | referenceStripScroll: 'horizontal', |
| | }); |
| | |
| | if (onPage) state.viewer.addHandler('page', onPage); |
| | }; |
| | |
| | |
| | async function initViewer() { |
| | const tileSources = await buildTileSources(state.canvases); |
| | setupViewer(tileSources, { onPage: (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'); |
| | } |
| | |
| | |
| | const fetchImageInfo = (() => { |
| | const cache = new Map(); |
| | return (serviceUrl) => { |
| | if (cache.has(serviceUrl)) return cache.get(serviceUrl); |
| | const promise = fetch(`${serviceUrl}/info.json`) |
| | .then(r => r.json()) |
| | .catch(() => null); |
| | cache.set(serviceUrl, promise); |
| | return promise; |
| | }; |
| | })(); |
| | |
| | |
| | const isLevel0 = (info) => { |
| | const p = info?.profile; |
| | return typeof p === 'string' ? p.includes('level0') |
| | : Array.isArray(p) ? p.some(v => typeof v === 'string' && v.includes('level0')) |
| | : false; |
| | }; |
| | |
| | |
| | const pickBestSize = (sizes, target) => |
| | [...sizes] |
| | .filter(s => Math.min(s.width, s.height) >= target) |
| | .sort((a, b) => a.width * a.height - b.width * b.height)[0] |
| | ?? null; |
| | |
| | |
| | const getBestImageUrl = async (canvas, target = 224) => { |
| | if (!canvas.imageServiceUrl) return canvas.imageUrl; |
| | |
| | const info = await fetchImageInfo(canvas.imageServiceUrl); |
| | |
| | if (info && isLevel0(info) && info.sizes?.length) { |
| | const size = pickBestSize(info.sizes, target); |
| | return size |
| | ? `${canvas.imageServiceUrl}/full/${size.width},${size.height}/0/default.jpg` |
| | : canvas.imageUrl; |
| | } |
| | |
| | |
| | return `${canvas.imageServiceUrl}/full/${target},${target}/0/default.jpg`; |
| | }; |
| | |
| | |
| | async function fetchImageAsObjectUrl(canvas) { |
| | const imageUrl = await getBestImageUrl(canvas); |
| | 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'); |
| | } |
| | |
| | |
| | async 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 = await buildTileSources(illustratedCanvases); |
| | setupViewer(tileSources); |
| | |
| | state.viewingIllustratedOnly = true; |
| | elements.viewIllustratedBtn.textContent = 'View All Pages'; |
| | updateStatus(`Viewing ${illustratedCanvases.length} illustrated pages.`, 'success'); |
| | } |
| | |
| | |
| | async function viewAllPages() { |
| | const tileSources = await buildTileSources(state.canvases); |
| | setupViewer(tileSources, { onPage: (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> |
| |
|