davanstrien HF Staff Claude Opus 4.5 commited on
Commit
efa9a57
·
1 Parent(s): 15a7ad9

Add IIIF Illustration Detector demo

Browse files

- Full client-side demo using transformers.js
- IIIF v2/v3 manifest support
- OpenSeadragon viewer integration
- Export as IIIF Annotations
- About panel and Report Issue functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Files changed (3) hide show
  1. README.md +47 -5
  2. index.html +1630 -18
  3. style.css +0 -28
README.md CHANGED
@@ -1,10 +1,52 @@
1
  ---
2
- title: Iiif Illustration Detector
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: static
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: IIIF Illustration Detector
3
+ emoji: 🖼️
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
+ short_description: Find illustrated pages in digitized historical books
9
  ---
10
 
11
+ # IIIF Illustration Detector
12
+
13
+ Automatically identify illustrated pages in digitized historical books using a lightweight AI model that runs entirely in your browser.
14
+
15
+ ## Features
16
+
17
+ - **Client-side inference**: Uses [transformers.js](https://huggingface.co/docs/transformers.js) - no server required
18
+ - **Privacy-preserving**: Images never leave your browser
19
+ - **IIIF v2 & v3 support**: Works with manifests from libraries worldwide
20
+ - **Fast**: ~20ms per page classification
21
+ - **Export**: Download results as IIIF Annotations
22
+
23
+ ## How to Use
24
+
25
+ 1. Select a sample manifest or paste your own IIIF manifest URL
26
+ 2. Click "Load Manifest" to fetch the book
27
+ 3. Click "Classify" to detect illustrated pages
28
+ 4. Browse results, adjust confidence threshold, export annotations
29
+
30
+ ## Model
31
+
32
+ Uses [small-models-for-glam/historical-illustration-detector](https://huggingface.co/small-models-for-glam/historical-illustration-detector) - a 2.5MB quantized MobileNetV2 fine-tuned for historical document classification.
33
+
34
+ - **Accuracy**: 95% on test set
35
+ - **Classes**: `illustrated` / `not-illustrated`
36
+
37
+ ## What counts as "illustrated"?
38
+
39
+ - Engravings, woodcuts, lithographs
40
+ - Photographs and artwork
41
+ - Maps, diagrams, charts
42
+ - Scientific illustrations
43
+
44
+ **Not counted**: Decorative drop caps, ornamental borders, printer's devices.
45
+
46
+ ## Part of small-models-for-glam
47
+
48
+ This tool is part of [small-models-for-glam](https://huggingface.co/small-models-for-glam) - efficient, task-specific models for cultural heritage applications.
49
+
50
+ ## Feedback
51
+
52
+ Found an incorrect prediction? Use the "Report Issue" button in the app, or open a [discussion](https://huggingface.co/small-models-for-glam/historical-illustration-detector/discussions).
index.html CHANGED
@@ -1,19 +1,1631 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>IIIF Illustration Detector</title>
7
+
8
+ <!-- OpenSeadragon -->
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/openseadragon.min.js"></script>
10
+
11
+ <style>
12
+ * { box-sizing: border-box; }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
16
+ margin: 0;
17
+ padding: 0;
18
+ background: #fafafa;
19
+ color: #222;
20
+ font-size: 14px;
21
+ line-height: 1.5;
22
+ }
23
+
24
+ .container {
25
+ display: grid;
26
+ grid-template-columns: 340px 1fr;
27
+ height: 100vh;
28
+ }
29
+
30
+ /* Sidebar - clean, minimal */
31
+ .sidebar {
32
+ background: #fff;
33
+ border-right: 1px solid #e0e0e0;
34
+ display: flex;
35
+ flex-direction: column;
36
+ overflow: hidden;
37
+ }
38
+
39
+ .sidebar-header {
40
+ padding: 16px 16px 12px;
41
+ border-bottom: 1px solid #eee;
42
+ }
43
+
44
+ .sidebar-header h1 {
45
+ margin: 0;
46
+ font-size: 15px;
47
+ font-weight: 600;
48
+ letter-spacing: -0.01em;
49
+ }
50
+
51
+ .sidebar-header p {
52
+ margin: 4px 0 0;
53
+ font-size: 12px;
54
+ color: #666;
55
+ }
56
+
57
+ .sidebar-header a {
58
+ color: #555;
59
+ text-decoration: none;
60
+ }
61
+
62
+ .sidebar-header a:hover {
63
+ color: #000;
64
+ text-decoration: underline;
65
+ }
66
+
67
+ /* About toggle and panel */
68
+ .about-toggle {
69
+ font-size: 11px;
70
+ color: #888;
71
+ background: none;
72
+ border: 1px solid #ddd;
73
+ border-radius: 3px;
74
+ padding: 2px 8px;
75
+ cursor: pointer;
76
+ margin-left: 8px;
77
+ }
78
+
79
+ .about-toggle:hover {
80
+ color: #333;
81
+ border-color: #999;
82
+ }
83
+
84
+ .about-panel {
85
+ background: #fafafa;
86
+ border-top: 1px solid #eee;
87
+ padding: 12px 16px;
88
+ font-size: 12px;
89
+ line-height: 1.6;
90
+ color: #444;
91
+ max-height: 300px;
92
+ overflow-y: auto;
93
+ }
94
+
95
+ .about-panel h3 {
96
+ font-size: 11px;
97
+ font-weight: 600;
98
+ text-transform: uppercase;
99
+ letter-spacing: 0.03em;
100
+ color: #888;
101
+ margin: 0 0 8px 0;
102
+ }
103
+
104
+ .about-panel h3:not(:first-child) {
105
+ margin-top: 12px;
106
+ }
107
+
108
+ .about-panel p {
109
+ margin: 0 0 8px 0;
110
+ }
111
+
112
+ .about-panel ul {
113
+ margin: 0 0 8px 0;
114
+ padding-left: 16px;
115
+ }
116
+
117
+ .about-panel li {
118
+ margin-bottom: 4px;
119
+ }
120
+
121
+ .about-panel a {
122
+ color: #555;
123
+ }
124
+
125
+ .about-panel .privacy-note {
126
+ background: #f0f7f0;
127
+ padding: 8px;
128
+ border-radius: 3px;
129
+ margin-top: 8px;
130
+ }
131
+
132
+ /* Report issue modal */
133
+ .modal-overlay {
134
+ display: none;
135
+ position: fixed;
136
+ top: 0;
137
+ left: 0;
138
+ right: 0;
139
+ bottom: 0;
140
+ background: rgba(0, 0, 0, 0.5);
141
+ z-index: 1000;
142
+ align-items: center;
143
+ justify-content: center;
144
+ }
145
+
146
+ .modal-overlay.visible {
147
+ display: flex;
148
+ }
149
+
150
+ .modal {
151
+ background: #fff;
152
+ border-radius: 6px;
153
+ padding: 20px;
154
+ max-width: 500px;
155
+ width: 90%;
156
+ max-height: 80vh;
157
+ overflow-y: auto;
158
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
159
+ }
160
+
161
+ .modal h2 {
162
+ margin: 0 0 12px 0;
163
+ font-size: 16px;
164
+ font-weight: 600;
165
+ }
166
+
167
+ .modal p {
168
+ margin: 0 0 12px 0;
169
+ font-size: 13px;
170
+ color: #555;
171
+ }
172
+
173
+ .modal-details {
174
+ background: #f5f5f5;
175
+ padding: 12px;
176
+ border-radius: 4px;
177
+ font-family: monospace;
178
+ font-size: 11px;
179
+ white-space: pre-wrap;
180
+ word-break: break-all;
181
+ margin-bottom: 16px;
182
+ max-height: 200px;
183
+ overflow-y: auto;
184
+ }
185
+
186
+ .modal-buttons {
187
+ display: flex;
188
+ gap: 8px;
189
+ flex-wrap: wrap;
190
+ }
191
+
192
+ .modal-buttons button,
193
+ .modal-buttons a {
194
+ flex: 1;
195
+ min-width: 120px;
196
+ text-align: center;
197
+ text-decoration: none;
198
+ }
199
+
200
+ .btn-copy.copied {
201
+ background: #2a6;
202
+ border-color: #2a6;
203
+ color: #fff;
204
+ }
205
+
206
+ /* Distribution sparkline - shows pattern across book */
207
+ .distribution-sparkline {
208
+ display: none;
209
+ height: 32px;
210
+ margin-bottom: 12px;
211
+ padding: 4px 0;
212
+ border-bottom: 1px solid #eee;
213
+ }
214
+
215
+ .distribution-sparkline.visible {
216
+ display: flex;
217
+ align-items: flex-end;
218
+ gap: 1px;
219
+ }
220
+
221
+ .distribution-bar {
222
+ flex: 1 1 0;
223
+ min-width: 0;
224
+ max-width: 8px;
225
+ background: #ddd;
226
+ border-radius: 1px 1px 0 0;
227
+ transition: background 0.2s;
228
+ cursor: pointer;
229
+ }
230
+
231
+ .distribution-bar.illustrated {
232
+ background: #2a6;
233
+ }
234
+
235
+ .distribution-bar:hover {
236
+ opacity: 0.7;
237
+ }
238
+
239
+ .sidebar-content {
240
+ flex: 1;
241
+ overflow-y: auto;
242
+ padding: 12px;
243
+ }
244
+
245
+ /* Form elements - understated */
246
+ label {
247
+ display: block;
248
+ font-size: 11px;
249
+ font-weight: 500;
250
+ text-transform: uppercase;
251
+ letter-spacing: 0.03em;
252
+ margin-bottom: 4px;
253
+ color: #888;
254
+ }
255
+
256
+ input[type="url"], input[type="text"], select {
257
+ width: 100%;
258
+ padding: 6px 8px;
259
+ border: 1px solid #ddd;
260
+ border-radius: 3px;
261
+ font-size: 13px;
262
+ margin-bottom: 10px;
263
+ background: #fff;
264
+ }
265
+
266
+ input[type="url"]:focus, select:focus {
267
+ outline: none;
268
+ border-color: #333;
269
+ }
270
+
271
+ /* Buttons - minimal */
272
+ button {
273
+ padding: 6px 12px;
274
+ border: 1px solid #ccc;
275
+ border-radius: 3px;
276
+ font-size: 12px;
277
+ font-weight: 500;
278
+ cursor: pointer;
279
+ background: #fff;
280
+ color: #333;
281
+ transition: all 0.15s;
282
+ }
283
+
284
+ button:hover:not(:disabled) {
285
+ border-color: #999;
286
+ background: #f5f5f5;
287
+ }
288
+
289
+ button:disabled {
290
+ opacity: 0.4;
291
+ cursor: not-allowed;
292
+ }
293
+
294
+ .btn-primary {
295
+ background: #333;
296
+ color: #fff;
297
+ border-color: #333;
298
+ }
299
+
300
+ .btn-primary:hover:not(:disabled) {
301
+ background: #444;
302
+ border-color: #444;
303
+ }
304
+
305
+ .btn-danger {
306
+ color: #c00;
307
+ border-color: #c00;
308
+ background: #fff;
309
+ }
310
+
311
+ .btn-danger:hover:not(:disabled) {
312
+ background: #fff5f5;
313
+ }
314
+
315
+ .btn-group {
316
+ display: flex;
317
+ gap: 6px;
318
+ margin-bottom: 12px;
319
+ }
320
+
321
+ /* Status - subtle inline */
322
+ .status-box {
323
+ padding: 8px 10px;
324
+ font-size: 12px;
325
+ margin-bottom: 12px;
326
+ border-left: 3px solid #ddd;
327
+ background: #fafafa;
328
+ color: #555;
329
+ }
330
+
331
+ .status-box.loading {
332
+ border-left-color: #f0ad4e;
333
+ background: #fffcf5;
334
+ }
335
+
336
+ .status-box.error {
337
+ border-left-color: #c00;
338
+ background: #fff8f8;
339
+ color: #900;
340
+ }
341
+
342
+ .status-box.success {
343
+ border-left-color: #2a6;
344
+ background: #f6fdf8;
345
+ color: #185;
346
+ }
347
+
348
+ /* Progress - thin and clean */
349
+ .progress-container {
350
+ margin-bottom: 12px;
351
+ }
352
+
353
+ .progress-bar {
354
+ width: 100%;
355
+ height: 3px;
356
+ background: #eee;
357
+ overflow: hidden;
358
+ }
359
+
360
+ .progress-fill {
361
+ height: 100%;
362
+ background: #333;
363
+ transition: width 0.2s ease;
364
+ }
365
+
366
+ .progress-text {
367
+ font-size: 11px;
368
+ color: #888;
369
+ margin-top: 4px;
370
+ font-variant-numeric: tabular-nums;
371
+ }
372
+
373
+ /* Controls - compact */
374
+ .controls {
375
+ margin-bottom: 12px;
376
+ padding-bottom: 12px;
377
+ border-bottom: 1px solid #eee;
378
+ }
379
+
380
+ .threshold-control {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 8px;
384
+ margin-bottom: 6px;
385
+ }
386
+
387
+ .threshold-control input[type="range"] {
388
+ flex: 1;
389
+ height: 3px;
390
+ -webkit-appearance: none;
391
+ background: #ddd;
392
+ border-radius: 2px;
393
+ }
394
+
395
+ .threshold-control input[type="range"]::-webkit-slider-thumb {
396
+ -webkit-appearance: none;
397
+ width: 12px;
398
+ height: 12px;
399
+ background: #333;
400
+ border-radius: 50%;
401
+ cursor: pointer;
402
+ }
403
+
404
+ .threshold-value {
405
+ min-width: 32px;
406
+ text-align: right;
407
+ font-size: 12px;
408
+ font-weight: 500;
409
+ font-variant-numeric: tabular-nums;
410
+ }
411
+
412
+ .checkbox-control {
413
+ display: flex;
414
+ align-items: center;
415
+ gap: 6px;
416
+ font-size: 12px;
417
+ color: #555;
418
+ }
419
+
420
+ .checkbox-control input {
421
+ width: 14px;
422
+ height: 14px;
423
+ }
424
+
425
+ /* Results header */
426
+ .results-header {
427
+ display: flex;
428
+ justify-content: space-between;
429
+ align-items: baseline;
430
+ margin-bottom: 8px;
431
+ padding-bottom: 6px;
432
+ border-bottom: 1px solid #eee;
433
+ }
434
+
435
+ .results-header h2 {
436
+ margin: 0;
437
+ font-size: 11px;
438
+ font-weight: 600;
439
+ text-transform: uppercase;
440
+ letter-spacing: 0.03em;
441
+ color: #888;
442
+ }
443
+
444
+ .results-count {
445
+ font-size: 12px;
446
+ color: #555;
447
+ font-variant-numeric: tabular-nums;
448
+ }
449
+
450
+ /* Results list - compact data table style */
451
+ .results-list {
452
+ list-style: none;
453
+ padding: 0;
454
+ margin: 0;
455
+ }
456
+
457
+ .result-item {
458
+ display: grid;
459
+ grid-template-columns: 40px 1fr auto;
460
+ align-items: center;
461
+ gap: 10px;
462
+ padding: 6px 4px;
463
+ cursor: pointer;
464
+ border-bottom: 1px solid #f0f0f0;
465
+ transition: background 0.1s;
466
+ }
467
+
468
+ .result-item:hover {
469
+ background: #f8f8f8;
470
+ }
471
+
472
+ .result-item.active {
473
+ background: #f0f7ff;
474
+ }
475
+
476
+ /* Thumbnail - small, functional */
477
+ .thumbnail {
478
+ width: 40px;
479
+ height: 40px;
480
+ object-fit: cover;
481
+ border-radius: 2px;
482
+ background: #f0f0f0;
483
+ }
484
+
485
+ .result-info {
486
+ min-width: 0;
487
+ display: flex;
488
+ flex-direction: column;
489
+ gap: 2px;
490
+ }
491
+
492
+ .result-label {
493
+ font-size: 13px;
494
+ font-weight: 500;
495
+ white-space: nowrap;
496
+ overflow: hidden;
497
+ text-overflow: ellipsis;
498
+ color: #222;
499
+ }
500
+
501
+ .result-status {
502
+ font-size: 11px;
503
+ color: #888;
504
+ }
505
+
506
+ /* Confidence visualization - sparkline style bar */
507
+ .result-confidence {
508
+ display: flex;
509
+ align-items: center;
510
+ gap: 6px;
511
+ font-size: 12px;
512
+ font-weight: 500;
513
+ font-variant-numeric: tabular-nums;
514
+ color: #666;
515
+ min-width: 70px;
516
+ justify-content: flex-end;
517
+ }
518
+
519
+ .confidence-bar {
520
+ width: 40px;
521
+ height: 4px;
522
+ background: #eee;
523
+ border-radius: 2px;
524
+ overflow: hidden;
525
+ }
526
+
527
+ .confidence-fill {
528
+ height: 100%;
529
+ background: #bbb;
530
+ transition: width 0.2s;
531
+ }
532
+
533
+ /* Illustrated items get a subtle green accent */
534
+ .result-item.illustrated .confidence-fill {
535
+ background: #2a6;
536
+ }
537
+
538
+ .result-item.illustrated .result-confidence {
539
+ color: #185;
540
+ }
541
+
542
+ .result-item.processing {
543
+ opacity: 0.6;
544
+ }
545
+
546
+ .result-item.processing .result-status {
547
+ color: #b80;
548
+ }
549
+
550
+ .result-item.error .result-status {
551
+ color: #c00;
552
+ }
553
+
554
+ /* Viewer */
555
+ .viewer-container {
556
+ background: #111;
557
+ position: relative;
558
+ }
559
+
560
+ #viewer {
561
+ width: 100%;
562
+ height: 100%;
563
+ }
564
+
565
+ .viewer-placeholder {
566
+ display: flex;
567
+ align-items: center;
568
+ justify-content: center;
569
+ height: 100%;
570
+ color: #666;
571
+ font-size: 13px;
572
+ }
573
+
574
+ /* Sample manifests */
575
+ .sample-manifests {
576
+ margin-bottom: 10px;
577
+ }
578
+
579
+ /* Responsive */
580
+ @media (max-width: 768px) {
581
+ .container {
582
+ grid-template-columns: 1fr;
583
+ grid-template-rows: auto 1fr;
584
+ }
585
+
586
+ .sidebar {
587
+ max-height: 45vh;
588
+ }
589
+ }
590
+ </style>
591
+ </head>
592
+ <body>
593
+ <div class="container">
594
+ <aside class="sidebar">
595
+ <div class="sidebar-header">
596
+ <h1>IIIF Illustration Detector <button class="about-toggle" id="about-toggle">About</button></h1>
597
+ <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>
598
+ </div>
599
+
600
+ <div class="about-panel" id="about-panel" style="display: none;">
601
+ <h3>What is this?</h3>
602
+ <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>
603
+
604
+ <h3>What counts as "illustrated"?</h3>
605
+ <ul>
606
+ <li>Engravings, woodcuts, lithographs</li>
607
+ <li>Photographs and artwork</li>
608
+ <li>Maps, diagrams, charts</li>
609
+ <li>Scientific illustrations</li>
610
+ </ul>
611
+ <p><strong>Not counted:</strong> Decorative drop caps, ornamental borders, printer's devices.</p>
612
+
613
+ <h3>What is IIIF?</h3>
614
+ <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>
615
+ <p><strong>Finding manifests:</strong> Look for IIIF logos on library websites, or search "[collection name] IIIF manifest".</p>
616
+
617
+ <h3>Confidence threshold</h3>
618
+ <p>Adjust sensitivity: higher values = stricter classification (fewer false positives, may miss some illustrations). Default 50% works well for most books.</p>
619
+
620
+ <h3>Accuracy</h3>
621
+ <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>
622
+
623
+ <div class="privacy-note">
624
+ <strong>Privacy:</strong> All processing happens in your browser. No images are sent to any server.
625
+ </div>
626
+ </div>
627
+
628
+ <div class="sidebar-content">
629
+ <!-- Sample manifests -->
630
+ <div class="sample-manifests">
631
+ <label for="sample-select">Try a sample manifest:</label>
632
+ <select id="sample-select">
633
+ <option value="">-- Select a sample --</option>
634
+ <option value="https://digital.library.villanova.edu/Item/vudl:293339/Manifest">Villanova - Dime Novel (32 pages)</option>
635
+ <option value="https://iiif.wellcomecollection.org/presentation/b18035723">Wellcome - Medical illustrations</option>
636
+ <option value="https://damsssl.llgc.org.uk/iiif/2.0/1108344/manifest.json">National Library of Wales</option>
637
+ <option value="https://iiif.bodleian.ox.ac.uk/iiif/manifest/e32a277e-91e2-4a6d-8ba6-cc4bad230410.json">Bodleian - Medieval Manuscript</option>
638
+ <option value="https://iiif.wellcomecollection.org/presentation/b28047345">Wellcome - Insect Transformations (570 pages)</option>
639
+ <option value="https://www.e-codices.unifr.ch/metadata/iiif/csg-0406/manifest.json">e-codices - Medieval Manuscript (643 pages)</option>
640
+ <option value="https://iiif.archive.org/iiif/Illustratednatuv1Good/manifest.json">Internet Archive - Natural History Goodrich (744 pages)</option>
641
+ <option value="https://iiif.archive.org/iiif/illustratednatur01wood/manifest.json">Internet Archive - Natural History Wood (820 pages)</option>
642
+ </select>
643
+ </div>
644
+
645
+ <!-- Manifest input -->
646
+ <div class="input-group">
647
+ <label for="manifest-url">Or enter a IIIF manifest URL:</label>
648
+ <input type="url" id="manifest-url" placeholder="https://example.org/manifest.json">
649
+ </div>
650
+
651
+ <!-- Status -->
652
+ <div class="status-box" id="status">
653
+ Ready. Select a sample or enter a manifest URL.
654
+ </div>
655
+
656
+ <!-- Progress -->
657
+ <div class="progress-container" id="progress-container" style="display: none;">
658
+ <div class="progress-bar">
659
+ <div class="progress-fill" id="progress-fill" style="width: 0%"></div>
660
+ </div>
661
+ <div class="progress-text" id="progress-text">0 / 0 pages</div>
662
+ </div>
663
+
664
+ <!-- Action buttons -->
665
+ <div class="btn-group">
666
+ <button class="btn-primary" id="load-btn">Load Manifest</button>
667
+ <button class="btn-primary" id="classify-btn" disabled>Classify</button>
668
+ <button class="btn-danger" id="stop-btn" disabled>Stop</button>
669
+ </div>
670
+
671
+ <!-- Controls -->
672
+ <div class="controls">
673
+ <label>Confidence Threshold:</label>
674
+ <div class="threshold-control">
675
+ <input type="range" id="threshold" min="0" max="100" value="50">
676
+ <span class="threshold-value" id="threshold-value">50%</span>
677
+ </div>
678
+
679
+ <label class="checkbox-control">
680
+ <input type="checkbox" id="show-only-illustrated">
681
+ Show only illustrated pages
682
+ </label>
683
+ </div>
684
+
685
+ <!-- Export & View buttons -->
686
+ <div class="btn-group">
687
+ <button class="btn-secondary" id="view-illustrated-btn" disabled>View Illustrated Only</button>
688
+ <button class="btn-secondary" id="export-btn" disabled>Export Annotations</button>
689
+ <button class="btn-secondary" id="report-btn" disabled title="Select a page first, then report if the prediction is wrong">Report Issue</button>
690
+ </div>
691
+
692
+ <!-- Results -->
693
+ <div class="results-header">
694
+ <h2>Pages</h2>
695
+ <span class="results-count" id="results-count">0 pages</span>
696
+ </div>
697
+
698
+ <!-- Distribution sparkline - shows where illustrations are in book -->
699
+ <div class="distribution-sparkline" id="distribution-sparkline"></div>
700
+
701
+ <ul class="results-list" id="results-list">
702
+ <!-- Populated dynamically -->
703
+ </ul>
704
+ </div>
705
+ </aside>
706
+
707
+ <main class="viewer-container">
708
+ <div id="viewer">
709
+ <div class="viewer-placeholder">
710
+ Load a manifest to view pages
711
+ </div>
712
+ </div>
713
+ </main>
714
+ </div>
715
+
716
+ <!-- Report Issue Modal -->
717
+ <div class="modal-overlay" id="report-modal">
718
+ <div class="modal">
719
+ <h2>Report Incorrect Prediction</h2>
720
+ <p>Help improve the model by reporting incorrect classifications. Copy the details below and paste them into a new discussion on HuggingFace.</p>
721
+ <div class="modal-details" id="report-details">
722
+ <!-- Populated by JavaScript -->
723
+ </div>
724
+ <div class="modal-buttons">
725
+ <button class="btn-primary btn-copy" id="copy-details-btn">Copy Details</button>
726
+ <a href="https://huggingface.co/small-models-for-glam/historical-illustration-detector/discussions/new"
727
+ target="_blank" rel="noopener" class="btn-primary"
728
+ style="display: inline-block; padding: 6px 12px; background: #333; color: #fff; border: 1px solid #333; border-radius: 3px;">
729
+ Open HF Discussion
730
+ </a>
731
+ <button class="btn-secondary" id="close-modal-btn">Close</button>
732
+ </div>
733
+ </div>
734
+ </div>
735
+
736
+ <script type="module">
737
+ import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/[email protected]';
738
+
739
+ // Global state
740
+ const state = {
741
+ manifest: null,
742
+ manifestUrl: null,
743
+ canvases: [],
744
+ results: new Map(),
745
+ classifier: null,
746
+ viewer: null,
747
+ isRunning: false,
748
+ abortController: null,
749
+ currentIndex: -1,
750
+ classifyStartTime: null,
751
+ avgTimePerPage: null,
752
+ viewingIllustratedOnly: false,
753
+ };
754
+
755
+ // DOM elements
756
+ const elements = {
757
+ aboutToggle: document.getElementById('about-toggle'),
758
+ aboutPanel: document.getElementById('about-panel'),
759
+ sampleSelect: document.getElementById('sample-select'),
760
+ manifestUrl: document.getElementById('manifest-url'),
761
+ status: document.getElementById('status'),
762
+ progressContainer: document.getElementById('progress-container'),
763
+ progressFill: document.getElementById('progress-fill'),
764
+ progressText: document.getElementById('progress-text'),
765
+ loadBtn: document.getElementById('load-btn'),
766
+ classifyBtn: document.getElementById('classify-btn'),
767
+ stopBtn: document.getElementById('stop-btn'),
768
+ exportBtn: document.getElementById('export-btn'),
769
+ viewIllustratedBtn: document.getElementById('view-illustrated-btn'),
770
+ threshold: document.getElementById('threshold'),
771
+ thresholdValue: document.getElementById('threshold-value'),
772
+ showOnlyIllustrated: document.getElementById('show-only-illustrated'),
773
+ resultsCount: document.getElementById('results-count'),
774
+ resultsList: document.getElementById('results-list'),
775
+ distributionSparkline: document.getElementById('distribution-sparkline'),
776
+ viewer: document.getElementById('viewer'),
777
+ reportBtn: document.getElementById('report-btn'),
778
+ reportModal: document.getElementById('report-modal'),
779
+ reportDetails: document.getElementById('report-details'),
780
+ copyDetailsBtn: document.getElementById('copy-details-btn'),
781
+ closeModalBtn: document.getElementById('close-modal-btn'),
782
+ };
783
+
784
+ // Initialize
785
+ async function init() {
786
+ // About panel toggle
787
+ elements.aboutToggle.addEventListener('click', () => {
788
+ const isVisible = elements.aboutPanel.style.display !== 'none';
789
+ elements.aboutPanel.style.display = isVisible ? 'none' : 'block';
790
+ elements.aboutToggle.textContent = isVisible ? 'About' : 'Close';
791
+ });
792
+
793
+ // Event listeners
794
+ elements.sampleSelect.addEventListener('change', (e) => {
795
+ if (e.target.value) {
796
+ elements.manifestUrl.value = e.target.value;
797
+ }
798
+ });
799
+
800
+ elements.loadBtn.addEventListener('click', loadManifest);
801
+ elements.classifyBtn.addEventListener('click', classifyAll);
802
+ elements.stopBtn.addEventListener('click', stopClassification);
803
+ elements.exportBtn.addEventListener('click', exportAnnotations);
804
+ elements.viewIllustratedBtn.addEventListener('click', viewIllustratedOnly);
805
+ elements.reportBtn.addEventListener('click', showReportModal);
806
+ elements.copyDetailsBtn.addEventListener('click', copyReportDetails);
807
+ elements.closeModalBtn.addEventListener('click', closeReportModal);
808
+ elements.reportModal.addEventListener('click', (e) => {
809
+ if (e.target === elements.reportModal) closeReportModal();
810
+ });
811
+
812
+ elements.threshold.addEventListener('input', (e) => {
813
+ elements.thresholdValue.textContent = `${e.target.value}%`;
814
+ filterResults();
815
+ renderDistributionSparkline();
816
+ });
817
+
818
+ elements.showOnlyIllustrated.addEventListener('change', filterResults);
819
+
820
+ // Load model in background
821
+ loadModel();
822
+ }
823
+
824
+ // Load model
825
+ async function loadModel() {
826
+ updateStatus('Loading AI model...', 'loading');
827
+
828
+ try {
829
+ state.classifier = await pipeline(
830
+ 'image-classification',
831
+ 'small-models-for-glam/historical-illustration-detector'
832
+ );
833
+ updateStatus('Model loaded. Ready to process manifests.');
834
+ } catch (error) {
835
+ updateStatus(`Error loading model: ${error.message}`, 'error');
836
+ console.error('Model loading error:', error);
837
+ }
838
+ }
839
+
840
+ // Load manifest
841
+ async function loadManifest() {
842
+ const url = elements.manifestUrl.value.trim();
843
+ if (!url) {
844
+ updateStatus('Please enter a manifest URL.', 'error');
845
+ return;
846
+ }
847
+
848
+ // Stop any running classification
849
+ if (state.isRunning) {
850
+ stopClassification();
851
+ }
852
+
853
+ // Clear sparkline from previous manifest
854
+ elements.distributionSparkline.innerHTML = '';
855
+ elements.distributionSparkline.classList.remove('visible');
856
+
857
+ // Reset UI state for new manifest
858
+ elements.progressContainer.style.display = 'none';
859
+ elements.exportBtn.disabled = true;
860
+ elements.viewIllustratedBtn.disabled = true;
861
+ elements.reportBtn.disabled = true;
862
+ state.viewingIllustratedOnly = false;
863
+ elements.viewIllustratedBtn.textContent = 'View Illustrated Only';
864
+
865
+ updateStatus('Fetching manifest...', 'loading');
866
+
867
+ try {
868
+ const response = await fetch(url);
869
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
870
+ const manifest = await response.json();
871
+
872
+ state.manifestUrl = url;
873
+ state.manifest = manifest;
874
+
875
+ // Detect version and extract canvases
876
+ const version = detectManifestVersion(manifest);
877
+ state.canvases = extractCanvases(manifest, version);
878
+ state.results.clear();
879
+
880
+ updateStatus(`Loaded ${state.canvases.length} pages (IIIF v${version})`, 'success');
881
+ elements.resultsCount.textContent = `${state.canvases.length} pages`;
882
+
883
+ // Render UI
884
+ renderCanvasList();
885
+ initViewer();
886
+
887
+ // Enable classify button
888
+ elements.classifyBtn.disabled = false;
889
+
890
+ } catch (error) {
891
+ updateStatus(`Error loading manifest: ${error.message}`, 'error');
892
+ console.error('Manifest loading error:', error);
893
+ }
894
+ }
895
+
896
+ // Detect IIIF version
897
+ function detectManifestVersion(manifest) {
898
+ const context = manifest['@context'];
899
+ if (Array.isArray(context)) {
900
+ if (context.some(c => typeof c === 'string' && c.includes('presentation/3'))) return 3;
901
+ } else if (typeof context === 'string' && context.includes('presentation/3')) {
902
+ return 3;
903
+ }
904
+ if (manifest.sequences) return 2;
905
+ if (manifest.items) return 3;
906
+ return 2;
907
+ }
908
+
909
+ // Extract canvases from manifest
910
+ function extractCanvases(manifest, version) {
911
+ const canvases = [];
912
+
913
+ if (version === 2) {
914
+ const sequences = manifest.sequences || [];
915
+ for (const sequence of sequences) {
916
+ const seqCanvases = sequence.canvases || [];
917
+ for (let i = 0; i < seqCanvases.length; i++) {
918
+ canvases.push(parseCanvasV2(seqCanvases[i], i));
919
+ }
920
+ }
921
+ } else {
922
+ const items = manifest.items || [];
923
+ for (let i = 0; i < items.length; i++) {
924
+ if (items[i].type === 'Canvas') {
925
+ canvases.push(parseCanvasV3(items[i], i));
926
+ }
927
+ }
928
+ }
929
+
930
+ return canvases;
931
+ }
932
+
933
+ // Parse v2 canvas
934
+ function parseCanvasV2(canvas, index) {
935
+ let imageUrl = null;
936
+ let imageServiceUrl = null;
937
+
938
+ const images = canvas.images || [];
939
+ if (images.length > 0) {
940
+ const resource = images[0].resource;
941
+ if (resource) {
942
+ imageUrl = resource['@id'] || resource.id;
943
+ const service = resource.service;
944
+ if (service) {
945
+ imageServiceUrl = service['@id'] || service.id;
946
+ }
947
+ }
948
+ }
949
+
950
+ return {
951
+ id: canvas['@id'] || canvas.id,
952
+ label: getLabel(canvas.label, index),
953
+ width: canvas.width,
954
+ height: canvas.height,
955
+ imageUrl,
956
+ imageServiceUrl,
957
+ thumbnail: getThumbnailUrl(canvas, imageServiceUrl),
958
+ index,
959
+ };
960
+ }
961
+
962
+ // Parse v3 canvas
963
+ function parseCanvasV3(canvas, index) {
964
+ let imageUrl = null;
965
+ let imageServiceUrl = null;
966
+
967
+ const annotationPages = canvas.items || [];
968
+ for (const page of annotationPages) {
969
+ const annotations = page.items || [];
970
+ for (const anno of annotations) {
971
+ if (anno.motivation === 'painting') {
972
+ const body = anno.body;
973
+ if (body) {
974
+ imageUrl = body.id;
975
+ const services = body.service || [];
976
+ const service = Array.isArray(services) ? services[0] : services;
977
+ if (service) {
978
+ imageServiceUrl = service['@id'] || service.id;
979
+ }
980
+ }
981
+ }
982
+ }
983
+ }
984
+
985
+ return {
986
+ id: canvas.id,
987
+ label: getLabel(canvas.label, index),
988
+ width: canvas.width,
989
+ height: canvas.height,
990
+ imageUrl,
991
+ imageServiceUrl,
992
+ thumbnail: getThumbnailUrl(canvas, imageServiceUrl),
993
+ index,
994
+ };
995
+ }
996
+
997
+ // Get label from various formats
998
+ function getLabel(label, index) {
999
+ if (!label) return `Page ${index + 1}`;
1000
+ if (typeof label === 'string') return label;
1001
+ if (typeof label === 'object') {
1002
+ const values = label.en || label.none || Object.values(label)[0];
1003
+ if (Array.isArray(values)) return values[0];
1004
+ return values || `Page ${index + 1}`;
1005
+ }
1006
+ return `Page ${index + 1}`;
1007
+ }
1008
+
1009
+ // Get thumbnail URL
1010
+ function getThumbnailUrl(canvas, imageServiceUrl) {
1011
+ const thumb = canvas.thumbnail;
1012
+ if (thumb) {
1013
+ const thumbUrl = Array.isArray(thumb) ? thumb[0] : thumb;
1014
+ return thumbUrl.id || thumbUrl['@id'] || thumbUrl;
1015
+ }
1016
+ if (imageServiceUrl) {
1017
+ return `${imageServiceUrl}/full/,100/0/default.jpg`;
1018
+ }
1019
+ return null;
1020
+ }
1021
+
1022
+ // Initialize OpenSeadragon
1023
+ function initViewer() {
1024
+ // Clear placeholder
1025
+ elements.viewer.innerHTML = '';
1026
+
1027
+ // Initialize viewer
1028
+ if (state.viewer) {
1029
+ state.viewer.destroy();
1030
+ }
1031
+
1032
+ const tileSources = state.canvases.map(canvas => {
1033
+ if (canvas.imageServiceUrl) {
1034
+ return `${canvas.imageServiceUrl}/info.json`;
1035
+ } else if (canvas.imageUrl) {
1036
+ return { type: 'image', url: canvas.imageUrl };
1037
+ }
1038
+ return null;
1039
+ }).filter(Boolean);
1040
+
1041
+ state.viewer = OpenSeadragon({
1042
+ id: 'viewer',
1043
+ prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/',
1044
+ tileSources: tileSources,
1045
+ sequenceMode: true,
1046
+ showNavigator: true,
1047
+ navigatorPosition: 'BOTTOM_RIGHT',
1048
+ showSequenceControl: true,
1049
+ showReferenceStrip: true,
1050
+ referenceStripScroll: 'horizontal',
1051
+ });
1052
+
1053
+ state.viewer.addHandler('page', (e) => {
1054
+ highlightResult(e.page);
1055
+ });
1056
+ }
1057
+
1058
+ // Navigate to canvas
1059
+ function navigateToCanvas(index) {
1060
+ if (state.viewer && index >= 0 && index < state.canvases.length) {
1061
+ state.viewer.goToPage(index);
1062
+ highlightResult(index);
1063
+ }
1064
+ }
1065
+
1066
+ // Highlight result item
1067
+ function highlightResult(index) {
1068
+ state.currentIndex = index;
1069
+ document.querySelectorAll('.result-item').forEach((item, i) => {
1070
+ item.classList.toggle('active', i === index);
1071
+ });
1072
+ }
1073
+
1074
+ // Classify all canvases with prefetching
1075
+ async function classifyAll() {
1076
+ if (!state.classifier || state.canvases.length === 0) {
1077
+ updateStatus('Model not loaded or no canvases.', 'error');
1078
+ return;
1079
+ }
1080
+
1081
+ state.isRunning = true;
1082
+ state.abortController = new AbortController();
1083
+ state.classifyStartTime = Date.now();
1084
+
1085
+ elements.classifyBtn.disabled = true;
1086
+ elements.stopBtn.disabled = false;
1087
+ elements.progressContainer.style.display = 'block';
1088
+
1089
+ let processed = 0;
1090
+ const total = state.canvases.length;
1091
+ let prefetchPromise = null;
1092
+
1093
+ updateStatus('Classifying pages...', 'loading');
1094
+
1095
+ for (let i = 0; i < state.canvases.length; i++) {
1096
+ if (state.abortController.signal.aborted) break;
1097
+
1098
+ const canvas = state.canvases[i];
1099
+ updateResultItem(canvas.index, { status: 'processing' });
1100
+
1101
+ try {
1102
+ // Get image: from prefetch if available, otherwise fetch now
1103
+ let objectUrl;
1104
+ if (prefetchPromise) {
1105
+ try {
1106
+ objectUrl = await prefetchPromise;
1107
+ } catch {
1108
+ // Prefetch failed, fetch directly
1109
+ objectUrl = await fetchImageAsObjectUrl(canvas);
1110
+ }
1111
+ } else {
1112
+ objectUrl = await fetchImageAsObjectUrl(canvas);
1113
+ }
1114
+
1115
+ // Start prefetching next image while we classify this one
1116
+ if (i + 1 < state.canvases.length) {
1117
+ prefetchPromise = fetchImageAsObjectUrl(state.canvases[i + 1]).catch(() => null);
1118
+ } else {
1119
+ prefetchPromise = null;
1120
+ }
1121
+
1122
+ // Classify current image
1123
+ const results = await state.classifier(objectUrl);
1124
+ URL.revokeObjectURL(objectUrl);
1125
+
1126
+ const illustrated = results.find(r => r.label === 'illustrated');
1127
+ const notIllustrated = results.find(r => r.label === 'not-illustrated');
1128
+ const illustratedScore = illustrated?.score || 0;
1129
+
1130
+ const result = {
1131
+ label: illustratedScore >= 0.5 ? 'illustrated' : 'not-illustrated',
1132
+ score: illustratedScore >= 0.5 ? illustratedScore : (notIllustrated?.score || 0),
1133
+ illustratedConfidence: illustratedScore,
1134
+ raw: results,
1135
+ };
1136
+
1137
+ state.results.set(canvas.id, result);
1138
+ updateResultItem(canvas.index, { status: 'done', result });
1139
+ } catch (error) {
1140
+ prefetchPromise = null; // Reset on error
1141
+ const errorResult = { error: error.message };
1142
+ state.results.set(canvas.id, errorResult);
1143
+ updateResultItem(canvas.index, { status: 'error', error: error.message });
1144
+ }
1145
+
1146
+ processed++;
1147
+
1148
+ // Update progress with ETA
1149
+ const elapsed = Date.now() - state.classifyStartTime;
1150
+ state.avgTimePerPage = elapsed / processed;
1151
+ const remaining = total - processed;
1152
+ const etaMs = remaining * state.avgTimePerPage;
1153
+ updateProgress(processed, total, etaMs);
1154
+ }
1155
+
1156
+ state.isRunning = false;
1157
+ elements.classifyBtn.disabled = false;
1158
+ elements.stopBtn.disabled = true;
1159
+ elements.exportBtn.disabled = false;
1160
+ elements.viewIllustratedBtn.disabled = false;
1161
+ elements.reportBtn.disabled = false;
1162
+
1163
+ const illustratedCount = countIllustrated();
1164
+ updateStatus(`Done! ${illustratedCount} illustrated pages found.`, 'success');
1165
+ }
1166
+
1167
+ // Fetch image and return object URL
1168
+ async function fetchImageAsObjectUrl(canvas) {
1169
+ let imageUrl;
1170
+ if (canvas.imageServiceUrl) {
1171
+ imageUrl = `${canvas.imageServiceUrl}/full/224,224/0/default.jpg`;
1172
+ } else {
1173
+ imageUrl = canvas.imageUrl;
1174
+ }
1175
+
1176
+ if (!imageUrl) throw new Error('No image URL');
1177
+
1178
+ const response = await fetch(imageUrl, { mode: 'cors' });
1179
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1180
+ const blob = await response.blob();
1181
+ return URL.createObjectURL(blob);
1182
+ }
1183
+
1184
+ // Classify single canvas
1185
+ async function classifyCanvas(canvas) {
1186
+ // Get image URL - use IIIF image service if available
1187
+ let imageUrl;
1188
+ if (canvas.imageServiceUrl) {
1189
+ imageUrl = `${canvas.imageServiceUrl}/full/224,224/0/default.jpg`;
1190
+ } else {
1191
+ imageUrl = canvas.imageUrl;
1192
+ }
1193
+
1194
+ if (!imageUrl) {
1195
+ throw new Error('No image URL');
1196
+ }
1197
+
1198
+ try {
1199
+ // Fetch image
1200
+ const response = await fetch(imageUrl, { mode: 'cors' });
1201
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1202
+ const blob = await response.blob();
1203
+
1204
+ // Create object URL
1205
+ const objectUrl = URL.createObjectURL(blob);
1206
+
1207
+ try {
1208
+ // Classify
1209
+ const results = await state.classifier(objectUrl);
1210
+ URL.revokeObjectURL(objectUrl);
1211
+
1212
+ // Find illustrated score
1213
+ const illustrated = results.find(r => r.label === 'illustrated');
1214
+ const notIllustrated = results.find(r => r.label === 'not-illustrated');
1215
+
1216
+ const illustratedScore = illustrated?.score || 0;
1217
+
1218
+ return {
1219
+ label: illustratedScore >= 0.5 ? 'illustrated' : 'not-illustrated',
1220
+ score: illustratedScore >= 0.5 ? illustratedScore : (notIllustrated?.score || 0),
1221
+ illustratedConfidence: illustratedScore,
1222
+ raw: results,
1223
+ };
1224
+ } catch (e) {
1225
+ URL.revokeObjectURL(objectUrl);
1226
+ throw e;
1227
+ }
1228
+ } catch (error) {
1229
+ if (error.message.includes('CORS') || error.name === 'TypeError') {
1230
+ throw new Error('CORS blocked');
1231
+ }
1232
+ throw error;
1233
+ }
1234
+ }
1235
+
1236
+ // Stop classification
1237
+ function stopClassification() {
1238
+ if (state.abortController) {
1239
+ state.abortController.abort();
1240
+ }
1241
+ state.isRunning = false;
1242
+ elements.stopBtn.disabled = true;
1243
+ elements.classifyBtn.disabled = false;
1244
+ updateStatus('Classification stopped.', 'error');
1245
+ }
1246
+
1247
+ // Render canvas list
1248
+ function renderCanvasList() {
1249
+ elements.resultsList.innerHTML = '';
1250
+
1251
+ state.canvases.forEach((canvas, index) => {
1252
+ const item = createResultItem(canvas, index);
1253
+ elements.resultsList.appendChild(item);
1254
+ });
1255
+ }
1256
+
1257
+ // Create result item element
1258
+ function createResultItem(canvas, index) {
1259
+ const item = document.createElement('li');
1260
+ item.className = 'result-item';
1261
+ item.dataset.canvasId = canvas.id;
1262
+ item.dataset.index = index;
1263
+ item.onclick = () => navigateToCanvas(index);
1264
+
1265
+ item.innerHTML = `
1266
+ <img class="thumbnail" src="${canvas.thumbnail || ''}" alt="" onerror="this.style.display='none'">
1267
+ <div class="result-info">
1268
+ <div class="result-label">${canvas.label}</div>
1269
+ <div class="result-status">Pending</div>
1270
+ </div>
1271
+ `;
1272
+
1273
+ return item;
1274
+ }
1275
+
1276
+ // Update result item
1277
+ function updateResultItem(index, { status, result, error }) {
1278
+ const item = elements.resultsList.children[index];
1279
+ if (!item) return;
1280
+
1281
+ const canvas = state.canvases[index];
1282
+ const statusEl = item.querySelector('.result-status');
1283
+ const existingConfidence = item.querySelector('.result-confidence');
1284
+ if (existingConfidence) existingConfidence.remove();
1285
+
1286
+ // Remove old classes
1287
+ item.classList.remove('illustrated', 'not-illustrated', 'processing', 'error');
1288
+
1289
+ if (status === 'processing') {
1290
+ item.classList.add('processing');
1291
+ statusEl.textContent = 'Processing...';
1292
+ } else if (status === 'done' && result) {
1293
+ const cls = result.label === 'illustrated' ? 'illustrated' : 'not-illustrated';
1294
+ item.classList.add(cls);
1295
+ statusEl.textContent = result.label;
1296
+
1297
+ // Add confidence with sparkline bar
1298
+ const pct = (result.illustratedConfidence * 100).toFixed(0);
1299
+ const confidence = document.createElement('div');
1300
+ confidence.className = 'result-confidence';
1301
+ confidence.innerHTML = `
1302
+ <div class="confidence-bar">
1303
+ <div class="confidence-fill" style="width: ${pct}%"></div>
1304
+ </div>
1305
+ ${pct}%
1306
+ `;
1307
+ item.appendChild(confidence);
1308
+ } else if (status === 'error') {
1309
+ item.classList.add('error');
1310
+ statusEl.textContent = `Error: ${error}`;
1311
+ }
1312
+
1313
+ filterResults();
1314
+ updateResultsCount();
1315
+ }
1316
+
1317
+ // Filter results based on threshold and checkbox
1318
+ function filterResults() {
1319
+ const showOnlyIllustrated = elements.showOnlyIllustrated.checked;
1320
+ const threshold = parseInt(elements.threshold.value) / 100;
1321
+
1322
+ Array.from(elements.resultsList.children).forEach((item, index) => {
1323
+ const canvas = state.canvases[index];
1324
+ const result = state.results.get(canvas.id);
1325
+
1326
+ let show = true;
1327
+
1328
+ if (showOnlyIllustrated && result) {
1329
+ show = result.illustratedConfidence >= threshold;
1330
+ }
1331
+
1332
+ item.style.display = show ? '' : 'none';
1333
+ });
1334
+
1335
+ updateResultsCount();
1336
+ }
1337
+
1338
+ // Count illustrated
1339
+ function countIllustrated() {
1340
+ const threshold = parseInt(elements.threshold.value) / 100;
1341
+ let count = 0;
1342
+ for (const result of state.results.values()) {
1343
+ if (!result.error && result.illustratedConfidence >= threshold) {
1344
+ count++;
1345
+ }
1346
+ }
1347
+ return count;
1348
+ }
1349
+
1350
+ // Update results count
1351
+ function updateResultsCount() {
1352
+ const total = state.canvases.length;
1353
+ const illustrated = countIllustrated();
1354
+ const processed = state.results.size;
1355
+
1356
+ if (processed > 0) {
1357
+ elements.resultsCount.textContent = `${illustrated} illustrated / ${total} total`;
1358
+ renderDistributionSparkline();
1359
+ } else {
1360
+ elements.resultsCount.textContent = `${total} pages`;
1361
+ }
1362
+ }
1363
+
1364
+ // Render distribution sparkline - shows pattern of illustrations across book
1365
+ function renderDistributionSparkline() {
1366
+ const threshold = parseInt(elements.threshold.value) / 100;
1367
+ const sparkline = elements.distributionSparkline;
1368
+
1369
+ // Only show if we have results
1370
+ if (state.results.size === 0) {
1371
+ sparkline.classList.remove('visible');
1372
+ return;
1373
+ }
1374
+
1375
+ sparkline.innerHTML = '';
1376
+ sparkline.classList.add('visible');
1377
+
1378
+ state.canvases.forEach((canvas, index) => {
1379
+ const result = state.results.get(canvas.id);
1380
+ const bar = document.createElement('div');
1381
+ bar.className = 'distribution-bar';
1382
+
1383
+ if (result && !result.error) {
1384
+ const confidence = result.illustratedConfidence;
1385
+ bar.style.height = `${Math.max(confidence * 100, 4)}%`;
1386
+
1387
+ if (confidence >= threshold) {
1388
+ bar.classList.add('illustrated');
1389
+ }
1390
+ } else {
1391
+ bar.style.height = '4%';
1392
+ }
1393
+
1394
+ bar.title = `${canvas.label}: ${result ? Math.round(result.illustratedConfidence * 100) : '?'}%`;
1395
+ bar.onclick = () => navigateToCanvas(index);
1396
+ sparkline.appendChild(bar);
1397
+ });
1398
+ }
1399
+
1400
+ // Update progress
1401
+ function updateProgress(current, total, etaMs = null) {
1402
+ const percent = total > 0 ? (current / total) * 100 : 0;
1403
+ elements.progressFill.style.width = `${percent}%`;
1404
+
1405
+ let text = `${current} / ${total} pages`;
1406
+ if (etaMs !== null && etaMs > 0) {
1407
+ const etaSec = Math.ceil(etaMs / 1000);
1408
+ if (etaSec < 60) {
1409
+ text += ` (~${etaSec}s remaining)`;
1410
+ } else {
1411
+ const mins = Math.floor(etaSec / 60);
1412
+ const secs = etaSec % 60;
1413
+ text += ` (~${mins}m ${secs}s remaining)`;
1414
+ }
1415
+ }
1416
+ elements.progressText.textContent = text;
1417
+ }
1418
+
1419
+ // Update status
1420
+ function updateStatus(message, type = '') {
1421
+ elements.status.textContent = message;
1422
+ elements.status.className = 'status-box';
1423
+ if (type) {
1424
+ elements.status.classList.add(type);
1425
+ }
1426
+ }
1427
+
1428
+ // Export annotations (IIIF format)
1429
+ function exportAnnotations() {
1430
+ const threshold = parseInt(elements.threshold.value) / 100;
1431
+ const annotations = [];
1432
+
1433
+ let annoIndex = 0;
1434
+ for (const canvas of state.canvases) {
1435
+ const result = state.results.get(canvas.id);
1436
+ if (!result || result.error) continue;
1437
+
1438
+ // Only include illustrated pages above threshold
1439
+ if (result.illustratedConfidence < threshold) continue;
1440
+
1441
+ annoIndex++;
1442
+
1443
+ annotations.push({
1444
+ id: `${state.manifestUrl}#annotation-${annoIndex}`,
1445
+ type: 'Annotation',
1446
+ motivation: 'tagging',
1447
+ body: {
1448
+ type: 'TextualBody',
1449
+ value: 'illustrated',
1450
+ format: 'text/plain',
1451
+ },
1452
+ target: canvas.id,
1453
+ });
1454
+ }
1455
+
1456
+ const annotationPage = {
1457
+ '@context': 'http://iiif.io/api/presentation/3/context.json',
1458
+ id: `${state.manifestUrl}#annotation-page`,
1459
+ type: 'AnnotationPage',
1460
+ label: {
1461
+ en: ['Illustration Classification Results']
1462
+ },
1463
+ summary: {
1464
+ en: [`${annotations.length} pages classified as illustrated (threshold: ${Math.round(threshold * 100)}%)`]
1465
+ },
1466
+ items: annotations,
1467
+ };
1468
+
1469
+ // Download
1470
+ const json = JSON.stringify(annotationPage, null, 2);
1471
+ const blob = new Blob([json], { type: 'application/ld+json' });
1472
+ const url = URL.createObjectURL(blob);
1473
+
1474
+ const a = document.createElement('a');
1475
+ a.href = url;
1476
+ a.download = 'illustration-annotations.json';
1477
+ a.click();
1478
+
1479
+ URL.revokeObjectURL(url);
1480
+
1481
+ updateStatus(`Exported ${annotations.length} annotations.`, 'success');
1482
+ }
1483
+
1484
+ // Toggle between viewing all pages and only illustrated pages
1485
+ function viewIllustratedOnly() {
1486
+ if (state.viewingIllustratedOnly) {
1487
+ // Reset to view all pages
1488
+ viewAllPages();
1489
+ return;
1490
+ }
1491
+
1492
+ const threshold = parseInt(elements.threshold.value) / 100;
1493
+
1494
+ // Get illustrated canvases
1495
+ const illustratedCanvases = state.canvases.filter(canvas => {
1496
+ const result = state.results.get(canvas.id);
1497
+ return result && !result.error && result.illustratedConfidence >= threshold;
1498
+ });
1499
+
1500
+ if (illustratedCanvases.length === 0) {
1501
+ updateStatus('No illustrated pages found above threshold.', 'error');
1502
+ return;
1503
+ }
1504
+
1505
+ // Build tile sources for only illustrated pages
1506
+ const tileSources = illustratedCanvases.map(canvas => {
1507
+ if (canvas.imageServiceUrl) {
1508
+ return `${canvas.imageServiceUrl}/info.json`;
1509
+ } else if (canvas.imageUrl) {
1510
+ return { type: 'image', url: canvas.imageUrl };
1511
+ }
1512
+ return null;
1513
+ }).filter(Boolean);
1514
+
1515
+ // Reinitialize viewer with only illustrated pages
1516
+ if (state.viewer) {
1517
+ state.viewer.destroy();
1518
+ }
1519
+
1520
+ elements.viewer.innerHTML = '';
1521
+
1522
+ state.viewer = OpenSeadragon({
1523
+ id: 'viewer',
1524
+ prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/',
1525
+ tileSources: tileSources,
1526
+ sequenceMode: true,
1527
+ showNavigator: true,
1528
+ navigatorPosition: 'BOTTOM_RIGHT',
1529
+ showSequenceControl: true,
1530
+ showReferenceStrip: true,
1531
+ referenceStripScroll: 'horizontal',
1532
+ });
1533
+
1534
+ state.viewingIllustratedOnly = true;
1535
+ elements.viewIllustratedBtn.textContent = 'View All Pages';
1536
+ updateStatus(`Viewing ${illustratedCanvases.length} illustrated pages.`, 'success');
1537
+ }
1538
+
1539
+ // Reset viewer to show all pages
1540
+ function viewAllPages() {
1541
+ if (state.viewer) {
1542
+ state.viewer.destroy();
1543
+ }
1544
+
1545
+ elements.viewer.innerHTML = '';
1546
+
1547
+ const tileSources = state.canvases.map(canvas => {
1548
+ if (canvas.imageServiceUrl) {
1549
+ return `${canvas.imageServiceUrl}/info.json`;
1550
+ } else if (canvas.imageUrl) {
1551
+ return { type: 'image', url: canvas.imageUrl };
1552
+ }
1553
+ return null;
1554
+ }).filter(Boolean);
1555
+
1556
+ state.viewer = OpenSeadragon({
1557
+ id: 'viewer',
1558
+ prefixUrl: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/',
1559
+ tileSources: tileSources,
1560
+ sequenceMode: true,
1561
+ showNavigator: true,
1562
+ navigatorPosition: 'BOTTOM_RIGHT',
1563
+ showSequenceControl: true,
1564
+ showReferenceStrip: true,
1565
+ referenceStripScroll: 'horizontal',
1566
+ });
1567
+
1568
+ state.viewer.addHandler('page', (e) => {
1569
+ highlightResult(e.page);
1570
+ });
1571
+
1572
+ state.viewingIllustratedOnly = false;
1573
+ elements.viewIllustratedBtn.textContent = 'View Illustrated Only';
1574
+ updateStatus(`Viewing all ${state.canvases.length} pages.`, 'success');
1575
+ }
1576
+
1577
+ // Show report modal for current page
1578
+ function showReportModal() {
1579
+ const canvas = state.canvases[state.currentIndex];
1580
+ if (!canvas) {
1581
+ updateStatus('Click a page in the list below to select it first.', 'error');
1582
+ return;
1583
+ }
1584
+
1585
+ const result = state.results.get(canvas.id);
1586
+ const confidence = result ? (result.illustratedConfidence * 100).toFixed(1) : 'N/A';
1587
+ const prediction = result ? result.label : 'not classified';
1588
+ const correctLabel = prediction === 'illustrated' ? 'not-illustrated' : 'illustrated';
1589
+
1590
+ // Build the report details
1591
+ const details = `## Incorrect Prediction Report
1592
+
1593
+ **Manifest:** ${state.manifestUrl}
1594
+ **Page:** ${canvas.label} (index ${canvas.index})
1595
+ **Image:** ${canvas.imageServiceUrl || canvas.imageUrl}
1596
+ **Predicted:** ${prediction} (${confidence}% confidence)
1597
+ **Should be:** ${correctLabel}
1598
+
1599
+ **Additional context:** [Optional - describe what's on the page]`;
1600
+
1601
+ elements.reportDetails.textContent = details;
1602
+ elements.reportModal.classList.add('visible');
1603
+ elements.copyDetailsBtn.textContent = 'Copy Details';
1604
+ elements.copyDetailsBtn.classList.remove('copied');
1605
+ }
1606
+
1607
+ // Copy report details to clipboard
1608
+ async function copyReportDetails() {
1609
+ try {
1610
+ await navigator.clipboard.writeText(elements.reportDetails.textContent);
1611
+ elements.copyDetailsBtn.textContent = 'Copied!';
1612
+ elements.copyDetailsBtn.classList.add('copied');
1613
+ setTimeout(() => {
1614
+ elements.copyDetailsBtn.textContent = 'Copy Details';
1615
+ elements.copyDetailsBtn.classList.remove('copied');
1616
+ }, 2000);
1617
+ } catch (err) {
1618
+ updateStatus('Failed to copy to clipboard', 'error');
1619
+ }
1620
+ }
1621
+
1622
+ // Close report modal
1623
+ function closeReportModal() {
1624
+ elements.reportModal.classList.remove('visible');
1625
+ }
1626
+
1627
+ // Start
1628
+ init();
1629
+ </script>
1630
+ </body>
1631
  </html>
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }