seg-models / pv_panel_models /comparison_tool.html
Mohamed-ENNHIRI
Solar Panel Segmentation app for HF Spaces
52efd90
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PV Panel Segmentation β€” Model Comparison</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: #1a2235;
--bg-hover: #243049;
--border: #2a3a55;
--text-primary: #e8ecf4;
--text-secondary: #8b95a8;
--text-muted: #5a6578;
--accent: #3b82f6;
--accent-glow: rgba(59, 130, 246, 0.25);
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--glass: rgba(26, 34, 53, 0.75);
--glass-border: rgba(255, 255, 255, 0.06);
--radius: 12px;
--radius-sm: 8px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Header ────────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.logo {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.header-stats {
display: flex;
gap: 20px;
font-size: 13px;
color: var(--text-secondary);
}
.stat-value {
font-weight: 600;
color: var(--text-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* ── Controls ──────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn:hover {
background: var(--bg-hover);
border-color: var(--accent);
}
.btn.active {
background: var(--accent);
border-color: var(--accent);
color: white;
box-shadow: 0 0 12px var(--accent-glow);
}
.btn-nav {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border-radius: 50%;
}
.search-box {
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
width: 220px;
transition: all var(--transition);
}
.search-box:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-box::placeholder { color: var(--text-muted); }
/* ── Main Layout ───────────────────────────────────────── */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar ───────────────────────────────────────────── */
.sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: width var(--transition);
}
.sidebar.collapsed {
width: 0;
overflow: hidden;
border-right: none;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-header span {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-secondary);
}
.image-grid {
flex: 1;
overflow-y: auto;
padding: 8px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
align-content: start;
}
.image-grid::-webkit-scrollbar { width: 4px; }
.image-grid::-webkit-scrollbar-track { background: transparent; }
.image-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.thumb {
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all var(--transition);
position: relative;
}
.thumb:hover { border-color: var(--accent); transform: scale(1.04); }
.thumb.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.7);
color: white;
font-size: 8px;
padding: 2px 4px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
/* ── Content Area ──────────────────────────────────────── */
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-center {
display: flex;
align-items: center;
gap: 10px;
}
.image-counter {
font-size: 13px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.image-counter .current {
color: var(--text-primary);
font-weight: 600;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.opacity-slider {
width: 100px;
accent-color: var(--accent);
cursor: pointer;
}
.opacity-label {
font-size: 12px;
color: var(--text-secondary);
min-width: 32px;
}
/* ── Comparison Grid ───────────────────────────────────── */
.comparison-area {
flex: 1;
overflow: auto;
padding: 20px;
}
.comparison-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
height: 100%;
min-height: 0;
}
.comparison-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
transition: all var(--transition);
}
.comparison-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.card-header {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.card-title {
font-size: 13px;
font-weight: 600;
}
.card-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 20px;
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
font-weight: 500;
}
.card-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: #0d1117;
min-height: 0;
}
.card-body img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
image-rendering: pixelated;
}
.overlay-container {
position: relative;
display: inline-block;
}
.overlay-container .mask-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
mix-blend-mode: screen;
pointer-events: none;
}
/* ── Loading / Empty States ────────────────────────────── */
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 12px;
font-size: 14px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-icon {
font-size: 48px;
opacity: 0.3;
}
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 1400px) {
.comparison-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 900px) {
.comparison-grid { grid-template-columns: repeat(2, 1fr); }
.sidebar { width: 200px; }
}
/* ── Keyboard hints ────────────────────────────────────── */
.kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 11px;
color: var(--text-muted);
font-family: inherit;
}
/* ── View mode ─────────────────────────────────────────── */
.comparison-grid.view-overlay .card-body { background: transparent; }
</style>
</head>
<body>
<!-- ── Header ─────────────────────────────────────────────── -->
<header class="header">
<div class="header-left">
<div class="logo">⬑ PV Seg Compare</div>
<div class="header-stats">
<span>Images: <span class="stat-value" id="totalImages">β€”</span></span>
<span>Models: <span class="stat-value">4</span></span>
</div>
</div>
<div class="header-right">
<input type="text" class="search-box" id="searchInput" placeholder="Search images…" autocomplete="off">
<button class="btn" id="toggleSidebar" title="Toggle sidebar">☰</button>
</div>
</header>
<!-- ── Main ───────────────────────────────────────────────── -->
<div class="main">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<span>Image Browser</span>
<span id="filteredCount"></span>
</div>
<div class="image-grid" id="imageGrid"></div>
</aside>
<!-- Content -->
<div class="content">
<div class="toolbar">
<div class="toolbar-left">
<button class="btn btn-nav" id="prevBtn" title="Previous (←)">β€Ή</button>
<div class="image-counter">
<span class="current" id="currentIdx">0</span>
<span>/ <span id="totalFiltered">0</span></span>
</div>
<button class="btn btn-nav" id="nextBtn" title="Next (β†’)">β€Ί</button>
</div>
<div class="toolbar-center">
<span id="currentFilename" style="font-size: 13px; color: var(--text-secondary);"></span>
</div>
<div class="toolbar-right">
<button class="btn" id="toggleOverlay">
<span>Overlay</span>
</button>
<label class="opacity-label">Ξ±</label>
<input type="range" class="opacity-slider" id="opacitySlider" min="0" max="100" value="50">
<span class="opacity-label" id="opacityValue">50%</span>
<span style="color: var(--text-muted); font-size: 11px; margin-left: 8px;">
<span class="kbd">←</span> <span class="kbd">β†’</span> navigate
</span>
</div>
</div>
<div class="comparison-area">
<div class="comparison-grid" id="comparisonGrid">
<!-- Filled by JS -->
<div class="empty-state" id="emptyState">
<div class="empty-icon">πŸ”</div>
<div>Loading manifest…</div>
<div class="spinner"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// ── State ────────────────────────────────────────────────
let manifest = null;
let filteredImages = [];
let currentIndex = 0;
let overlayMode = false;
let maskOpacity = 0.5;
// Model display colors for overlay mode
const MODEL_COLORS = {
'segnet': '#3b82f6',
'unet': '#10b981',
'segformer_b0': '#f59e0b',
'segformer_b5': '#ef4444',
};
const MODEL_DISPLAY_NAMES = {
'segnet': 'SegNet (CNN)',
'unet': 'UNet',
'segformer_b0': 'SegFormer-B0',
'segformer_b5': 'SegFormer-B5',
};
// ── DOM refs ─────────────────────────────────────────────
const searchInput = document.getElementById('searchInput');
const imageGrid = document.getElementById('imageGrid');
const comparisonGrid = document.getElementById('comparisonGrid');
const emptyState = document.getElementById('emptyState');
const currentIdxEl = document.getElementById('currentIdx');
const totalFilteredEl = document.getElementById('totalFiltered');
const totalImagesEl = document.getElementById('totalImages');
const filteredCountEl = document.getElementById('filteredCount');
const currentFilenameEl = document.getElementById('currentFilename');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const toggleOverlayBtn = document.getElementById('toggleOverlay');
const opacitySlider = document.getElementById('opacitySlider');
const opacityValueEl = document.getElementById('opacityValue');
const sidebar = document.getElementById('sidebar');
const toggleSidebarBtn = document.getElementById('toggleSidebar');
// ── Init ─────────────────────────────────────────────────
async function init() {
try {
const resp = await fetch('predictions/manifest.json');
manifest = await resp.json();
totalImagesEl.textContent = manifest.images.length.toLocaleString();
filteredImages = manifest.images;
renderSidebar();
showImage(0);
} catch (e) {
emptyState.innerHTML = `
<div class="empty-icon">⚠️</div>
<div>Could not load manifest.json</div>
<div style="font-size: 12px; color: var(--text-muted);">
Run: <code>python -m http.server 8000</code> from the pv_panel_models directory
</div>
`;
}
}
// ── Sidebar rendering ────────────────────────────────────
function renderSidebar() {
imageGrid.innerHTML = '';
filteredCountEl.textContent = `${filteredImages.length}`;
totalFilteredEl.textContent = filteredImages.length.toLocaleString();
// Only render visible portion for performance (virtual scroll would be better but keep it simple)
const fragment = document.createDocumentFragment();
const maxVisible = Math.min(filteredImages.length, 300); // Show first 300 for perf
for (let i = 0; i < maxVisible; i++) {
const img = filteredImages[i];
const thumb = document.createElement('div');
thumb.className = 'thumb' + (i === currentIndex ? ' active' : '');
thumb.dataset.index = i;
thumb.innerHTML = `
<img src="${img.original_path}" loading="lazy" alt="${img.filename}">
<div class="thumb-label">${img.filename.replace('.jpg','')}</div>
`;
thumb.addEventListener('click', () => {
currentIndex = parseInt(thumb.dataset.index);
showImage(currentIndex);
});
fragment.appendChild(thumb);
}
if (filteredImages.length > maxVisible) {
const more = document.createElement('div');
more.style.cssText = 'grid-column: 1/-1; text-align:center; padding:8px; font-size:11px; color:var(--text-muted)';
more.textContent = `+${(filteredImages.length - maxVisible).toLocaleString()} more (use search to filter)`;
fragment.appendChild(more);
}
imageGrid.appendChild(fragment);
}
// ── Show image ───────────────────────────────────────────
function showImage(index) {
if (!manifest || filteredImages.length === 0) return;
currentIndex = Math.max(0, Math.min(index, filteredImages.length - 1));
const img = filteredImages[currentIndex];
currentIdxEl.textContent = (currentIndex + 1).toLocaleString();
currentFilenameEl.textContent = img.filename;
// Update active thumbnail
document.querySelectorAll('.thumb').forEach((t, i) => {
t.classList.toggle('active', i === currentIndex);
});
// Scroll active thumb into view
const activeThumb = document.querySelector('.thumb.active');
if (activeThumb) {
activeThumb.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Build comparison cards
comparisonGrid.innerHTML = '';
// Original image card
const origCard = createCard('Original', 'source', img.original_path, null);
comparisonGrid.appendChild(origCard);
// Model prediction cards
const modelKeys = manifest.model_short_names || Object.keys(img.masks);
for (const key of modelKeys) {
if (!img.masks[key]) continue;
const name = MODEL_DISPLAY_NAMES[key] || key;
const maskPath = img.masks[key];
const card = createCard(name, key, img.original_path, maskPath);
comparisonGrid.appendChild(card);
}
}
function createCard(title, key, originalPath, maskPath) {
const card = document.createElement('div');
card.className = 'comparison-card';
const badge = key === 'source' ? 'RGB' : 'mask';
const badgeColor = key === 'source' ? 'rgba(16,185,129,0.15)' : 'rgba(59,130,246,0.15)';
const badgeTextColor = key === 'source' ? '#10b981' : '#3b82f6';
card.innerHTML = `
<div class="card-header">
<span class="card-title">${title}</span>
<span class="card-badge" style="background:${badgeColor}; color:${badgeTextColor}">${badge}</span>
</div>
<div class="card-body"></div>
`;
const body = card.querySelector('.card-body');
if (key === 'source') {
// Just the original image
const imgEl = new Image();
imgEl.src = originalPath;
imgEl.alt = title;
body.appendChild(imgEl);
} else if (overlayMode && maskPath) {
// Overlay: original + mask on top
const container = document.createElement('div');
container.className = 'overlay-container';
const origImg = new Image();
origImg.src = originalPath;
origImg.alt = 'original';
const maskImg = new Image();
maskImg.src = maskPath;
maskImg.alt = 'mask';
maskImg.className = 'mask-overlay';
maskImg.style.opacity = maskOpacity;
// Tint the mask with model color
const color = MODEL_COLORS[key] || '#3b82f6';
maskImg.style.filter = `opacity(1)`;
maskImg.style.mixBlendMode = 'screen';
container.appendChild(origImg);
container.appendChild(maskImg);
body.appendChild(container);
} else if (maskPath) {
// Just the mask
const imgEl = new Image();
imgEl.src = maskPath;
imgEl.alt = title;
body.appendChild(imgEl);
}
return card;
}
// ── Search ───────────────────────────────────────────────
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim().toLowerCase();
if (!manifest) return;
if (query === '') {
filteredImages = manifest.images;
} else {
filteredImages = manifest.images.filter(img =>
img.filename.toLowerCase().includes(query)
);
}
currentIndex = 0;
renderSidebar();
if (filteredImages.length > 0) {
showImage(0);
} else {
comparisonGrid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<div class="empty-icon">πŸ”</div>
<div>No images match "${searchInput.value}"</div>
</div>
`;
currentIdxEl.textContent = '0';
currentFilenameEl.textContent = '';
}
});
// ── Navigation ───────────────────────────────────────────
prevBtn.addEventListener('click', () => showImage(currentIndex - 1));
nextBtn.addEventListener('click', () => showImage(currentIndex + 1));
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft') { e.preventDefault(); showImage(currentIndex - 1); }
if (e.key === 'ArrowRight') { e.preventDefault(); showImage(currentIndex + 1); }
if (e.key === 'o' || e.key === 'O') { toggleOverlayBtn.click(); }
});
// ── Overlay toggle ───────────────────────────────────────
toggleOverlayBtn.addEventListener('click', () => {
overlayMode = !overlayMode;
toggleOverlayBtn.classList.toggle('active', overlayMode);
comparisonGrid.classList.toggle('view-overlay', overlayMode);
showImage(currentIndex);
});
// ── Opacity ──────────────────────────────────────────────
opacitySlider.addEventListener('input', () => {
maskOpacity = opacitySlider.value / 100;
opacityValueEl.textContent = `${opacitySlider.value}%`;
// Update all overlays live
document.querySelectorAll('.mask-overlay').forEach(el => {
el.style.opacity = maskOpacity;
});
});
// ── Sidebar toggle ───────────────────────────────────────
toggleSidebarBtn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
});
// ── Start ────────────────────────────────────────────────
init();
</script>
</body>
</html>