pytractoviz 0.2.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pytractoviz/html.py ADDED
@@ -0,0 +1,845 @@
1
+ """HTML report generation for quality checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def create_quality_check_html(
11
+ data: dict[str, dict[str, dict[str, str]]],
12
+ output_file: str,
13
+ title: str = "Tractography Quality Check",
14
+ items_per_page: int = 50,
15
+ ) -> None:
16
+ """Create an interactive HTML quality check report.
17
+
18
+ This function generates an HTML file with filtering, search, and pagination
19
+ capabilities for efficiently reviewing large datasets of visualizations.
20
+
21
+ Parameters
22
+ ----------
23
+ data : dict
24
+ Nested dictionary structure: {subject_id: {tract_name: {media_type: file_path}}}
25
+ Example:
26
+ {
27
+ "sub-001": {
28
+ "AF_L": {
29
+ "image": "path/to/image.png",
30
+ "plot": "path/to/plot.png",
31
+ "gif": "path/to/animation.gif",
32
+ "video": "path/to/video.mp4"
33
+ }
34
+ }
35
+ }
36
+ output_file : str
37
+ Path where the HTML file will be saved.
38
+ title : str, optional
39
+ Title for the HTML report. Default is "Tractography Quality Check".
40
+ items_per_page : int, optional
41
+ Number of items to display per page. Default is 50.
42
+
43
+ Notes
44
+ -----
45
+ Supported media types in the data dictionary:
46
+ - "image": Static images (PNG, JPG, etc.)
47
+ - "plot": Matplotlib plots or other static plots
48
+ - "gif": Animated GIF files
49
+ - "video": Video files (MP4, WebM, etc.)
50
+
51
+ The HTML report includes:
52
+ - Filtering by subject and tract
53
+ - Search functionality
54
+ - Pagination for efficient loading
55
+ - Thumbnail grid view with expandable detail views
56
+ - Support for images, plots, GIFs, and videos
57
+ """
58
+ # Convert file paths to relative paths for HTML
59
+ html_dir = Path(output_file).parent
60
+ data_processed: dict[str, dict[str, dict[str, str]]] = {}
61
+
62
+ for subject_id, tracts in data.items():
63
+ data_processed[subject_id] = {}
64
+ for tract_name, media in tracts.items():
65
+ data_processed[subject_id][tract_name] = {}
66
+ for media_type, file_path in media.items():
67
+ # Handle numeric scores (like shape_similarity_score)
68
+ if isinstance(file_path, (int, float)):
69
+ data_processed[subject_id][tract_name][media_type] = str(file_path)
70
+ elif file_path and os.path.exists(file_path):
71
+ # Convert to relative path from HTML file location
72
+ try:
73
+ rel_path = os.path.relpath(file_path, html_dir)
74
+ data_processed[subject_id][tract_name][media_type] = rel_path
75
+ except ValueError:
76
+ # If paths are on different drives (Windows), use absolute
77
+ data_processed[subject_id][tract_name][media_type] = file_path
78
+ elif file_path:
79
+ # Store as-is if it's a string but file doesn't exist (might be a score string)
80
+ data_processed[subject_id][tract_name][media_type] = file_path
81
+
82
+ # Extract unique subjects and tracts for filters
83
+ subjects = sorted(data_processed.keys())
84
+ tract_names: list[str] = sorted({tract for tracts_dict in data_processed.values() for tract in tracts_dict})
85
+
86
+ # Generate HTML
87
+ html_content = f"""<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="UTF-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
+ <title>{title}</title>
93
+ <style>
94
+ * {{
95
+ margin: 0;
96
+ padding: 0;
97
+ box-sizing: border-box;
98
+ }}
99
+
100
+ body {{
101
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
102
+ background: #f5f5f5;
103
+ color: #333;
104
+ line-height: 1.6;
105
+ }}
106
+
107
+ .header {{
108
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
109
+ color: white;
110
+ padding: 2rem;
111
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
112
+ }}
113
+
114
+ .header h1 {{
115
+ margin-bottom: 0.5rem;
116
+ }}
117
+
118
+ .header .stats {{
119
+ opacity: 0.9;
120
+ font-size: 0.9rem;
121
+ }}
122
+
123
+ .controls {{
124
+ background: white;
125
+ padding: 1.5rem;
126
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
127
+ position: sticky;
128
+ top: 0;
129
+ z-index: 100;
130
+ }}
131
+
132
+ .controls-grid {{
133
+ display: grid;
134
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
135
+ gap: 1rem;
136
+ margin-bottom: 1rem;
137
+ }}
138
+
139
+ .control-group {{
140
+ display: flex;
141
+ flex-direction: column;
142
+ }}
143
+
144
+ .control-group label {{
145
+ font-weight: 600;
146
+ margin-bottom: 0.5rem;
147
+ font-size: 0.9rem;
148
+ color: #555;
149
+ }}
150
+
151
+ .control-group select,
152
+ .control-group input {{
153
+ padding: 0.6rem;
154
+ border: 2px solid #e0e0e0;
155
+ border-radius: 4px;
156
+ font-size: 0.9rem;
157
+ transition: border-color 0.3s;
158
+ }}
159
+
160
+ .control-group select:focus,
161
+ .control-group input:focus {{
162
+ outline: none;
163
+ border-color: #667eea;
164
+ }}
165
+
166
+ .pagination {{
167
+ display: flex;
168
+ justify-content: center;
169
+ align-items: center;
170
+ gap: 0.5rem;
171
+ margin-top: 1rem;
172
+ }}
173
+
174
+ .pagination button {{
175
+ padding: 0.5rem 1rem;
176
+ border: 2px solid #667eea;
177
+ background: white;
178
+ color: #667eea;
179
+ border-radius: 4px;
180
+ cursor: pointer;
181
+ font-size: 0.9rem;
182
+ transition: all 0.3s;
183
+ }}
184
+
185
+ .pagination button:hover:not(:disabled) {{
186
+ background: #667eea;
187
+ color: white;
188
+ }}
189
+
190
+ .pagination button:disabled {{
191
+ opacity: 0.5;
192
+ cursor: not-allowed;
193
+ }}
194
+
195
+ .pagination .page-info {{
196
+ padding: 0 1rem;
197
+ font-weight: 600;
198
+ }}
199
+
200
+ .grid-container {{
201
+ padding: 2rem;
202
+ max-width: 1800px;
203
+ margin: 0 auto;
204
+ }}
205
+
206
+ .grid {{
207
+ display: grid;
208
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
209
+ gap: 1.5rem;
210
+ }}
211
+
212
+ .item-card {{
213
+ background: white;
214
+ border-radius: 8px;
215
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
216
+ overflow: hidden;
217
+ transition: transform 0.3s, box-shadow 0.3s;
218
+ cursor: pointer;
219
+ }}
220
+
221
+ .item-card:hover {{
222
+ transform: translateY(-4px);
223
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
224
+ }}
225
+
226
+ .item-header {{
227
+ padding: 1rem;
228
+ background: #f8f9fa;
229
+ border-bottom: 1px solid #e0e0e0;
230
+ }}
231
+
232
+ .item-header h3 {{
233
+ font-size: 1rem;
234
+ margin-bottom: 0.25rem;
235
+ color: #333;
236
+ }}
237
+
238
+ .item-header .tract-name {{
239
+ font-size: 0.85rem;
240
+ color: #667eea;
241
+ font-weight: 600;
242
+ }}
243
+
244
+ .item-media {{
245
+ position: relative;
246
+ width: 100%;
247
+ padding-top: 75%; /* 4:3 aspect ratio */
248
+ background: #f0f0f0;
249
+ overflow: hidden;
250
+ }}
251
+
252
+ .item-media img,
253
+ .item-media video {{
254
+ position: absolute;
255
+ top: 0;
256
+ left: 0;
257
+ width: 100%;
258
+ height: 100%;
259
+ object-fit: contain;
260
+ background: white;
261
+ }}
262
+
263
+ .item-media .media-placeholder {{
264
+ position: absolute;
265
+ top: 50%;
266
+ left: 50%;
267
+ transform: translate(-50%, -50%);
268
+ color: #999;
269
+ font-size: 0.9rem;
270
+ }}
271
+
272
+ .item-footer {{
273
+ padding: 0.75rem 1rem;
274
+ display: flex;
275
+ gap: 0.5rem;
276
+ flex-wrap: wrap;
277
+ }}
278
+
279
+ .media-badge {{
280
+ padding: 0.25rem 0.5rem;
281
+ background: #e3f2fd;
282
+ color: #1976d2;
283
+ border-radius: 12px;
284
+ font-size: 0.75rem;
285
+ font-weight: 600;
286
+ }}
287
+
288
+ .score-badge {{
289
+ padding: 0.5rem 1rem;
290
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
291
+ color: white;
292
+ border-radius: 8px;
293
+ font-size: 1.1rem;
294
+ font-weight: 700;
295
+ text-align: center;
296
+ display: inline-block;
297
+ margin: 0.5rem 0;
298
+ }}
299
+
300
+ .comparison-container {{
301
+ display: grid;
302
+ grid-template-columns: 1fr 1fr;
303
+ gap: 1rem;
304
+ margin: 1rem 0;
305
+ }}
306
+
307
+ .comparison-item {{
308
+ background: #f8f9fa;
309
+ border-radius: 4px;
310
+ padding: 0.5rem;
311
+ text-align: center;
312
+ }}
313
+
314
+ .comparison-item h5 {{
315
+ margin-bottom: 0.5rem;
316
+ color: #555;
317
+ font-size: 0.85rem;
318
+ text-transform: uppercase;
319
+ letter-spacing: 0.5px;
320
+ }}
321
+
322
+ .comparison-item img {{
323
+ width: 100%;
324
+ height: auto;
325
+ border-radius: 4px;
326
+ background: white;
327
+ }}
328
+
329
+ @media (max-width: 768px) {{
330
+ .comparison-container {{
331
+ grid-template-columns: 1fr;
332
+ }}
333
+ }}
334
+
335
+ .modal {{
336
+ display: none;
337
+ position: fixed;
338
+ z-index: 1000;
339
+ left: 0;
340
+ top: 0;
341
+ width: 100%;
342
+ height: 100%;
343
+ background: rgba(0,0,0,0.9);
344
+ overflow: auto;
345
+ }}
346
+
347
+ .modal-content {{
348
+ position: relative;
349
+ margin: 2% auto;
350
+ max-width: 90%;
351
+ max-height: 90vh;
352
+ background: white;
353
+ border-radius: 8px;
354
+ padding: 2rem;
355
+ overflow-y: auto;
356
+ }}
357
+
358
+ .modal-header {{
359
+ display: flex;
360
+ justify-content: space-between;
361
+ align-items: center;
362
+ margin-bottom: 1.5rem;
363
+ padding-bottom: 1rem;
364
+ border-bottom: 2px solid #e0e0e0;
365
+ }}
366
+
367
+ .modal-header h2 {{
368
+ color: #333;
369
+ }}
370
+
371
+ .close {{
372
+ color: #aaa;
373
+ font-size: 2rem;
374
+ font-weight: bold;
375
+ cursor: pointer;
376
+ line-height: 1;
377
+ }}
378
+
379
+ .close:hover {{
380
+ color: #000;
381
+ }}
382
+
383
+ .modal-media-container {{
384
+ display: grid;
385
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
386
+ gap: 1.5rem;
387
+ }}
388
+
389
+ .modal-media-item {{
390
+ background: #f8f9fa;
391
+ border-radius: 4px;
392
+ padding: 1rem;
393
+ }}
394
+
395
+ .modal-media-item h4 {{
396
+ margin-bottom: 0.5rem;
397
+ color: #555;
398
+ text-transform: uppercase;
399
+ font-size: 0.85rem;
400
+ letter-spacing: 0.5px;
401
+ }}
402
+
403
+ .modal-media-item img,
404
+ .modal-media-item video {{
405
+ width: 100%;
406
+ height: auto;
407
+ border-radius: 4px;
408
+ background: white;
409
+ }}
410
+
411
+ .no-results {{
412
+ text-align: center;
413
+ padding: 4rem 2rem;
414
+ color: #999;
415
+ }}
416
+
417
+ .no-results h3 {{
418
+ margin-bottom: 0.5rem;
419
+ color: #666;
420
+ }}
421
+
422
+ @media (max-width: 768px) {{
423
+ .grid {{
424
+ grid-template-columns: 1fr;
425
+ }}
426
+
427
+ .controls-grid {{
428
+ grid-template-columns: 1fr;
429
+ }}
430
+
431
+ .modal-content {{
432
+ max-width: 95%;
433
+ padding: 1rem;
434
+ }}
435
+
436
+ .modal-media-container {{
437
+ grid-template-columns: 1fr;
438
+ }}
439
+ }}
440
+ </style>
441
+ </head>
442
+ <body>
443
+ <div class="header">
444
+ <h1>{title}</h1>
445
+ <div class="stats">
446
+ <span id="total-items">{len(subjects)} subjects x {len(tract_names)} tracts</span> |
447
+ <span id="filtered-count">Showing all items</span>
448
+ </div>
449
+ </div>
450
+
451
+ <div class="controls">
452
+ <div class="controls-grid">
453
+ <div class="control-group">
454
+ <label for="subject-filter">Filter by Subject</label>
455
+ <select id="subject-filter">
456
+ <option value="">All Subjects</option>
457
+ {"".join(f'<option value="{s}">{s}</option>' for s in subjects)}
458
+ </select>
459
+ </div>
460
+ <div class="control-group">
461
+ <label for="tract-filter">Filter by Tract</label>
462
+ <select id="tract-filter">
463
+ <option value="">All Tracts</option>
464
+ {"".join(f'<option value="{t}">{t}</option>' for t in tract_names)}
465
+ </select>
466
+ </div>
467
+ <div class="control-group">
468
+ <label for="search">Search</label>
469
+ <input type="text" id="search" placeholder="Search subject or tract...">
470
+ </div>
471
+ </div>
472
+ <div class="pagination">
473
+ <button id="prev-page" disabled>Previous</button>
474
+ <span class="page-info">
475
+ Page <span id="current-page">1</span> of <span id="total-pages">1</span>
476
+ </span>
477
+ <button id="next-page">Next</button>
478
+ </div>
479
+ </div>
480
+
481
+ <div class="grid-container">
482
+ <div class="grid" id="items-grid"></div>
483
+ <div class="no-results" id="no-results" style="display: none;">
484
+ <h3>No items found</h3>
485
+ <p>Try adjusting your filters or search terms</p>
486
+ </div>
487
+ </div>
488
+
489
+ <div id="modal" class="modal">
490
+ <div class="modal-content">
491
+ <div class="modal-header">
492
+ <h2 id="modal-title"></h2>
493
+ <span class="close">&times;</span>
494
+ </div>
495
+ <div class="modal-media-container" id="modal-media"></div>
496
+ </div>
497
+ </div>
498
+
499
+ <script>
500
+ const data = {json.dumps(data_processed)};
501
+ const itemsPerPage = {items_per_page};
502
+
503
+ let currentPage = 1;
504
+ let filteredData = [];
505
+
506
+ function getMediaType(filePath) {{
507
+ if (!filePath) return null;
508
+ // Check if it's a numeric score (for shape_similarity_score)
509
+ if (typeof filePath === 'number' || (!isNaN(parseFloat(filePath)) && isFinite(filePath))) {{
510
+ return 'score';
511
+ }}
512
+ const ext = filePath.split('.').pop().toLowerCase();
513
+ if (['gif'].includes(ext)) return 'gif';
514
+ if (['mp4', 'webm', 'ogg'].includes(ext)) return 'video';
515
+ if (['png', 'jpg', 'jpeg', 'svg', 'webp'].includes(ext)) return 'image';
516
+ return 'image';
517
+ }}
518
+
519
+ function isComparisonType(mediaType) {{
520
+ return ['before_after_cci', 'atlas_comparison', 'shape_similarity_image'].includes(mediaType);
521
+ }}
522
+
523
+ function flattenData() {{
524
+ const items = [];
525
+ for (const [subject, tracts] of Object.entries(data)) {{
526
+ for (const [tract, media] of Object.entries(tracts)) {{
527
+ items.push({{ subject, tract, media }});
528
+ }}
529
+ }}
530
+ return items;
531
+ }}
532
+
533
+ function filterData() {{
534
+ const subjectFilter = document.getElementById('subject-filter').value;
535
+ const tractFilter = document.getElementById('tract-filter').value;
536
+ const searchTerm = document.getElementById('search').value.toLowerCase();
537
+
538
+ filteredData = flattenData().filter(item => {{
539
+ const matchSubject = !subjectFilter || item.subject === subjectFilter;
540
+ const matchTract = !tractFilter || item.tract === tractFilter;
541
+ const matchSearch = !searchTerm ||
542
+ item.subject.toLowerCase().includes(searchTerm) ||
543
+ item.tract.toLowerCase().includes(searchTerm);
544
+ return matchSubject && matchTract && matchSearch;
545
+ }});
546
+
547
+ currentPage = 1;
548
+ renderItems();
549
+ updatePagination();
550
+ }}
551
+
552
+ function renderItems() {{
553
+ const grid = document.getElementById('items-grid');
554
+ const noResults = document.getElementById('no-results');
555
+ const filteredCount = document.getElementById('filtered-count');
556
+
557
+ if (filteredData.length === 0) {{
558
+ grid.style.display = 'none';
559
+ noResults.style.display = 'block';
560
+ filteredCount.textContent = 'No items found';
561
+ return;
562
+ }}
563
+
564
+ grid.style.display = 'grid';
565
+ noResults.style.display = 'none';
566
+ filteredCount.textContent = `Showing ${{filteredData.length}} item${{filteredData.length !== 1 ? 's' : ''}}`;
567
+
568
+ const start = (currentPage - 1) * itemsPerPage;
569
+ const end = start + itemsPerPage;
570
+ const pageItems = filteredData.slice(start, end);
571
+
572
+ grid.innerHTML = pageItems.map(item => {{
573
+ const mediaTypes = Object.keys(item.media);
574
+ // Find primary media (skip scores)
575
+ const primaryMedia = Object.entries(item.media).find(([type, path]) => {{
576
+ const mt = getMediaType(path);
577
+ return mt !== 'score' && mt !== null;
578
+ }});
579
+ const primaryMediaPath = primaryMedia ? primaryMedia[1] : null;
580
+ const mediaType = primaryMediaPath ? getMediaType(primaryMediaPath) : null;
581
+
582
+ // Find scores to display
583
+ const scores = Object.entries(item.media)
584
+ .filter(([type, path]) => getMediaType(path) === 'score')
585
+ .map(([type, path]) => ({{
586
+ type: type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase()),
587
+ value: parseFloat(path)
588
+ }}));
589
+
590
+ let mediaHtml = '<div class="media-placeholder">No media available</div>';
591
+ if (primaryMediaPath) {{
592
+ if (mediaType === 'video') {{
593
+ mediaHtml = `<video src="${{primaryMediaPath}}" muted loop></video>`;
594
+ }} else if (mediaType === 'gif' || mediaType === 'image') {{
595
+ mediaHtml = `<img src="${{primaryMediaPath}}" alt="${{item.tract}}" loading="lazy">`;
596
+ }}
597
+ }} else if (scores.length > 0) {{
598
+ // If only scores available, display them
599
+ mediaHtml = scores.map(score =>
600
+ `<div class="score-badge" style="margin: 0.5rem;">${{score.type}}: ${{score.value.toFixed(3)}}</div>`
601
+ ).join('');
602
+ }}
603
+
604
+ const badges = mediaTypes.map(type =>
605
+ `<span class="media-badge">${{type}}</span>`
606
+ ).join('');
607
+
608
+ return `
609
+ <div class="item-card" onclick="openModal('${{item.subject}}', '${{item.tract}}')">
610
+ <div class="item-header">
611
+ <h3>${{item.subject}}</h3>
612
+ <div class="tract-name">${{item.tract}}</div>
613
+ </div>
614
+ <div class="item-media">
615
+ ${{mediaHtml}}
616
+ </div>
617
+ <div class="item-footer">
618
+ ${{badges}}
619
+ </div>
620
+ </div>
621
+ `;
622
+ }}).join('');
623
+ }}
624
+
625
+ function updatePagination() {{
626
+ const totalPages = Math.ceil(filteredData.length / itemsPerPage);
627
+ document.getElementById('current-page').textContent = currentPage;
628
+ document.getElementById('total-pages').textContent = totalPages || 1;
629
+ document.getElementById('prev-page').disabled = currentPage === 1;
630
+ document.getElementById('next-page').disabled = currentPage >= totalPages;
631
+ }}
632
+
633
+ function openModal(subject, tract) {{
634
+ const item = filteredData.find(i => i.subject === subject && i.tract === tract);
635
+ if (!item) return;
636
+
637
+ document.getElementById('modal-title').textContent = `${{subject}} - ${{tract}}`;
638
+ const modalMedia = document.getElementById('modal-media');
639
+
640
+ // Separate scores and media files
641
+ const scores = [];
642
+ const mediaFiles = [];
643
+ const comparisons = [];
644
+
645
+ Object.entries(item.media).forEach(([type, path]) => {{
646
+ if (!path) return;
647
+ const mediaType = getMediaType(path);
648
+
649
+ if (mediaType === 'score') {{
650
+ scores.push({{ type, value: parseFloat(path) }});
651
+ }} else if (isComparisonType(type)) {{
652
+ comparisons.push({{ type, path }});
653
+ }} else {{
654
+ mediaFiles.push({{ type, path }});
655
+ }}
656
+ }});
657
+
658
+ let html = '';
659
+
660
+ // Display scores as badges
661
+ if (scores.length > 0) {{
662
+ html += scores.map(score => `
663
+ <div class="modal-media-item">
664
+ <h4>${{score.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
665
+ <div class="score-badge">${{score.value.toFixed(3)}}</div>
666
+ </div>
667
+ `).join('');
668
+ }}
669
+
670
+ // Display comparisons side-by-side
671
+ if (comparisons.length > 0) {{
672
+ html += comparisons.map(comp => {{
673
+ const mediaType = getMediaType(comp.path);
674
+ let mediaHtml = '';
675
+
676
+ if (mediaType === 'video') {{
677
+ mediaHtml = `<video src="${{comp.path}}" controls autoplay></video>`;
678
+ }} else if (mediaType === 'gif' || mediaType === 'image') {{
679
+ mediaHtml = `<img src="${{comp.path}}" alt="${{comp.type}}">`;
680
+ }}
681
+
682
+ // For before_after_cci, the image is already side-by-side, just add labels
683
+ if (comp.type === 'before_after_cci') {{
684
+ return `
685
+ <div class="modal-media-item">
686
+ <h4>${{comp.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
687
+ ${{mediaHtml}}
688
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 0.5rem; text-align: center;">
689
+ <div style="font-size: 0.85rem; color: #555; font-weight: 600;">Before CCI</div>
690
+ <div style="font-size: 0.85rem; color: #555; font-weight: 600;">After CCI</div>
691
+ </div>
692
+ </div>
693
+ `;
694
+ }} else if (comp.type === 'atlas_comparison') {{
695
+ // Check if we have separate subject and atlas images
696
+ const subjectImg = item.media.subject_image || item.media.atlas_comparison_subject;
697
+ const atlasImg = item.media.atlas_image || item.media.atlas_comparison_atlas;
698
+
699
+ if (subjectImg && atlasImg) {{
700
+ const subjectType = getMediaType(subjectImg);
701
+ const atlasType = getMediaType(atlasImg);
702
+ let subjectHtml = subjectType === 'image' || subjectType === 'gif'
703
+ ? `<img src="${{subjectImg}}" alt="Subject">`
704
+ : `<img src="${{comp.path}}" alt="Subject">`;
705
+ let atlasHtml = atlasType === 'image' || atlasType === 'gif'
706
+ ? `<img src="${{atlasImg}}" alt="Atlas">`
707
+ : `<img src="${{comp.path}}" alt="Atlas">`;
708
+
709
+ return `
710
+ <div class="modal-media-item">
711
+ <h4>${{comp.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
712
+ <div class="comparison-container">
713
+ <div class="comparison-item">
714
+ <h5>Subject</h5>
715
+ ${{subjectHtml}}
716
+ </div>
717
+ <div class="comparison-item">
718
+ <h5>Atlas</h5>
719
+ ${{atlasHtml}}
720
+ </div>
721
+ </div>
722
+ </div>
723
+ `;
724
+ }} else {{
725
+ // Single comparison image
726
+ return `
727
+ <div class="modal-media-item">
728
+ <h4>${{comp.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
729
+ ${{mediaHtml}}
730
+ </div>
731
+ `;
732
+ }}
733
+ }} else if (comp.type === 'shape_similarity_image') {{
734
+ // Check if we have separate subject and atlas images
735
+ const subjectImg = item.media.shape_similarity_subject;
736
+ const atlasImg = item.media.shape_similarity_atlas;
737
+
738
+ if (subjectImg && atlasImg) {{
739
+ const subjectType = getMediaType(subjectImg);
740
+ const atlasType = getMediaType(atlasImg);
741
+ let subjectHtml = subjectType === 'image' || subjectType === 'gif'
742
+ ? `<img src="${{subjectImg}}" alt="Subject">`
743
+ : `<img src="${{comp.path}}" alt="Subject">`;
744
+ let atlasHtml = atlasType === 'image' || atlasType === 'gif'
745
+ ? `<img src="${{atlasImg}}" alt="Atlas">`
746
+ : `<img src="${{comp.path}}" alt="Atlas">`;
747
+
748
+ return `
749
+ <div class="modal-media-item">
750
+ <h4>${{comp.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
751
+ <div class="comparison-container">
752
+ <div class="comparison-item">
753
+ <h5>Subject</h5>
754
+ ${{subjectHtml}}
755
+ </div>
756
+ <div class="comparison-item">
757
+ <h5>Atlas</h5>
758
+ ${{atlasHtml}}
759
+ </div>
760
+ </div>
761
+ </div>
762
+ `;
763
+ }} else {{
764
+ // Single overlay image
765
+ return `
766
+ <div class="modal-media-item">
767
+ <h4>${{comp.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
768
+ ${{mediaHtml}}
769
+ </div>
770
+ `;
771
+ }}
772
+ }} else {{
773
+ return `
774
+ <div class="modal-media-item">
775
+ <h4>${{comp.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
776
+ ${{mediaHtml}}
777
+ </div>
778
+ `;
779
+ }}
780
+ }}).join('');
781
+ }}
782
+
783
+ // Display other media files
784
+ html += mediaFiles.map(media => {{
785
+ const mediaType = getMediaType(media.path);
786
+ let mediaHtml = '';
787
+
788
+ if (mediaType === 'video') {{
789
+ mediaHtml = `<video src="${{media.path}}" controls autoplay></video>`;
790
+ }} else if (mediaType === 'gif' || mediaType === 'image') {{
791
+ mediaHtml = `<img src="${{media.path}}" alt="${{media.type}}">`;
792
+ }}
793
+
794
+ return `
795
+ <div class="modal-media-item">
796
+ <h4>${{media.type.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}}</h4>
797
+ ${{mediaHtml}}
798
+ </div>
799
+ `;
800
+ }}).join('');
801
+
802
+ modalMedia.innerHTML = html;
803
+
804
+ document.getElementById('modal').style.display = 'block';
805
+ }}
806
+
807
+ function closeModal() {{
808
+ document.getElementById('modal').style.display = 'none';
809
+ }}
810
+
811
+ // Event listeners
812
+ document.getElementById('subject-filter').addEventListener('change', filterData);
813
+ document.getElementById('tract-filter').addEventListener('change', filterData);
814
+ document.getElementById('search').addEventListener('input', filterData);
815
+ document.getElementById('prev-page').addEventListener('click', () => {{
816
+ if (currentPage > 1) {{
817
+ currentPage--;
818
+ renderItems();
819
+ updatePagination();
820
+ window.scrollTo({{ top: 0, behavior: 'smooth' }});
821
+ }}
822
+ }});
823
+ document.getElementById('next-page').addEventListener('click', () => {{
824
+ const totalPages = Math.ceil(filteredData.length / itemsPerPage);
825
+ if (currentPage < totalPages) {{
826
+ currentPage++;
827
+ renderItems();
828
+ updatePagination();
829
+ window.scrollTo({{ top: 0, behavior: 'smooth' }});
830
+ }}
831
+ }});
832
+ document.querySelector('.close').addEventListener('click', closeModal);
833
+ document.getElementById('modal').addEventListener('click', (e) => {{
834
+ if (e.target.id === 'modal') closeModal();
835
+ }});
836
+
837
+ // Initialize
838
+ filterData();
839
+ </script>
840
+ </body>
841
+ </html>"""
842
+
843
+ # Write HTML file
844
+ with open(output_file, "w", encoding="utf-8") as f:
845
+ f.write(html_content)