746 lines
33 KiB
HTML
746 lines
33 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>OCR Annotator</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Unbounded:wght@400;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #0d0d0d; --surface: #161616; --surface2: #1f1f1f;
|
||
--border: #2a2a2a; --accent: #e8ff3d; --accent2: #3dffc8;
|
||
--text: #e8e8e8; --muted: #555; --danger: #ff4d4d; --labeled: #3dffc8;
|
||
--warn: #ffb13d;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { background: var(--bg); color: var(--text); font-family: 'JetBrains Mono', monospace; min-height: 100vh; }
|
||
|
||
/* ── LOBBY ── */
|
||
#lobby {
|
||
min-height: 100vh; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; gap: 32px; padding: 40px;
|
||
}
|
||
.lobby-logo { font-family: 'Unbounded', sans-serif; font-size: 22px; font-weight: 700; color: var(--accent); letter-spacing: 0.08em; }
|
||
.lobby-logo span { color: var(--muted); }
|
||
.lobby-sub { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; }
|
||
.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; width: 100%; max-width: 800px; }
|
||
.folder-card {
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
|
||
padding: 20px; cursor: pointer; transition: all 0.15s; position: relative; overflow: hidden;
|
||
}
|
||
.folder-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||
.folder-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--accent); transform: scaleX(0); transition: transform 0.2s; transform-origin: left; }
|
||
.folder-card:hover::before { transform: scaleX(1); }
|
||
.folder-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; word-break: break-all; }
|
||
.folder-bar { height: 2px; background: var(--border); border-radius: 1px; margin-bottom: 8px; overflow: hidden; }
|
||
.folder-bar-fill { height: 100%; background: var(--accent2); border-radius: 1px; transition: width 0.4s; }
|
||
.folder-stats { font-size: 10px; color: var(--muted); display: flex; justify-content: space-between; }
|
||
.folder-stats .done { color: var(--accent2); }
|
||
.no-folders { text-align: center; color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||
|
||
/* ── APP SHELL ── */
|
||
#app { display: none; grid-template-rows: auto 1fr auto; min-height: 100vh; }
|
||
|
||
header {
|
||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||
padding: 12px 20px; display: flex; align-items: center; gap: 16px;
|
||
}
|
||
.back-btn {
|
||
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
|
||
padding: 5px 10px; border: 1px solid var(--border); background: transparent;
|
||
color: var(--muted); cursor: pointer; border-radius: 2px; letter-spacing: 0.05em;
|
||
transition: all 0.15s; white-space: nowrap;
|
||
}
|
||
.back-btn:hover { border-color: var(--text); color: var(--text); }
|
||
.logo { font-family: 'Unbounded', sans-serif; font-size: 12px; font-weight: 700; color: var(--accent); letter-spacing: 0.08em; white-space: nowrap; }
|
||
.logo span { color: var(--muted); }
|
||
.subfolder-tag { font-size: 10px; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); padding: 3px 8px; border-radius: 2px; }
|
||
.progress-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||
.progress-bar { height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; }
|
||
.progress-fill { height: 100%; background: var(--accent); transition: width 0.4s; border-radius: 1px; }
|
||
.progress-text { font-size: 10px; color: var(--muted); }
|
||
.progress-text strong { color: var(--accent); }
|
||
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||
.btn {
|
||
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
|
||
letter-spacing: 0.06em; padding: 7px 14px; border: 1px solid var(--border);
|
||
background: transparent; color: var(--text); cursor: pointer; text-transform: uppercase;
|
||
transition: all 0.15s; border-radius: 2px; white-space: nowrap;
|
||
}
|
||
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.btn-accent { background: var(--accent); color: #000; border-color: var(--accent); }
|
||
.btn-accent:hover { background: #fff; border-color: #fff; color: #000; }
|
||
.btn-danger { border-color: var(--danger); color: var(--danger); }
|
||
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||
.btn-warn { border-color: var(--warn); color: var(--warn); }
|
||
.btn-warn:hover { background: var(--warn); color: #000; border-color: var(--warn); }
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.btn:disabled:hover { border-color: var(--border); color: var(--text); background: transparent; }
|
||
|
||
.auto-hint {
|
||
font-size: 10px; color: var(--warn); letter-spacing: 0.05em;
|
||
white-space: nowrap; border: 1px solid var(--warn); border-radius: 2px;
|
||
padding: 3px 8px;
|
||
}
|
||
#recheck-text { color: var(--warn); }
|
||
|
||
/* Confirm dialog */
|
||
.confirm-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 200;
|
||
display: flex; align-items: center; justify-content: center;
|
||
backdrop-filter: blur(3px);
|
||
}
|
||
.confirm-box {
|
||
background: var(--surface); border: 1px solid var(--danger); border-radius: 4px;
|
||
padding: 28px 32px; max-width: 380px; width: 90%; text-align: center;
|
||
}
|
||
.confirm-icon { font-size: 32px; margin-bottom: 12px; }
|
||
.confirm-title { font-family: 'Unbounded', sans-serif; font-size: 13px; color: var(--text); margin-bottom: 8px; }
|
||
.confirm-sub { font-size: 11px; color: var(--muted); margin-bottom: 24px; line-height: 1.6; word-break: break-all; }
|
||
.confirm-btns { display: flex; gap: 10px; justify-content: center; }
|
||
|
||
|
||
/* ── MAIN ── */
|
||
main { display: grid; grid-template-columns: 240px 1fr; overflow: hidden; height: calc(100vh - 54px - 42px); }
|
||
|
||
/* SIDEBAR */
|
||
.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
||
.sidebar-header {
|
||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||
font-size: 9px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.filter-btns { display: flex; gap: 3px; }
|
||
.filter-btn {
|
||
font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 3px 6px;
|
||
border: 1px solid var(--border); background: transparent; color: var(--muted);
|
||
cursor: pointer; border-radius: 2px; letter-spacing: 0.04em; transition: all 0.15s;
|
||
}
|
||
.filter-btn.active { border-color: var(--accent); color: var(--accent); }
|
||
.image-list { flex: 1; overflow-y: auto; padding: 6px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||
.image-item {
|
||
padding: 7px 9px; border-radius: 2px; cursor: pointer; font-size: 10px; color: var(--muted);
|
||
display: flex; align-items: center; gap: 7px; border: 1px solid transparent;
|
||
margin-bottom: 2px; transition: background 0.1s;
|
||
}
|
||
.image-item:hover { background: var(--surface2); color: var(--text); }
|
||
.image-item.active { background: var(--surface2); border-color: var(--accent); color: var(--text); }
|
||
.image-item.labeled { color: var(--labeled); }
|
||
.image-item.labeled .dot { background: var(--labeled); }
|
||
.image-item.auto { color: var(--warn); }
|
||
.image-item.auto .dot { background: var(--warn); }
|
||
.item-badge { font-size: 9px; flex-shrink: 0; }
|
||
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); flex-shrink: 0; }
|
||
.item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
||
/* PANEL */
|
||
.panel { display: flex; flex-direction: column; overflow: hidden; }
|
||
|
||
/* IMAGE AREA */
|
||
.image-area {
|
||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||
background: var(--bg); position: relative; overflow: hidden; cursor: grab;
|
||
}
|
||
.image-area:active { cursor: grabbing; }
|
||
.image-area::before {
|
||
content: ''; position: absolute; inset: 0;
|
||
background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||
background-size: 28px 28px; opacity: 0.25; pointer-events: none;
|
||
}
|
||
.zoom-controls {
|
||
position: absolute; top: 12px; right: 12px; z-index: 10;
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.zoom-btn {
|
||
width: 28px; height: 28px; border: 1px solid var(--border); background: var(--surface);
|
||
color: var(--text); font-size: 14px; cursor: pointer; border-radius: 2px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all 0.15s; font-family: monospace;
|
||
}
|
||
.zoom-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.zoom-level {
|
||
font-size: 9px; color: var(--muted); text-align: center;
|
||
font-family: 'JetBrains Mono', monospace; letter-spacing: 0.05em;
|
||
}
|
||
|
||
/* The zoomable canvas */
|
||
#zoom-canvas {
|
||
position: absolute; inset: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transform-origin: center center;
|
||
will-change: transform;
|
||
}
|
||
.img-frame { border: 1px solid var(--border); background: var(--surface2); padding: 4px; box-shadow: 0 0 40px rgba(0,0,0,0.7); }
|
||
#current-image { display: block; max-width: 800px; max-height: 280px; object-fit: contain; image-rendering: pixelated; }
|
||
|
||
.empty-state { text-align: center; color: var(--muted); z-index: 1; }
|
||
.empty-state .icon { font-size: 40px; margin-bottom: 12px; }
|
||
.empty-state p { font-size: 11px; line-height: 1.7; }
|
||
|
||
/* INPUT */
|
||
.input-area { background: var(--surface); border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||
.input-row { display: flex; gap: 10px; align-items: center; }
|
||
.input-label { font-size: 10px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; }
|
||
#text-input {
|
||
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||
font-family: 'JetBrains Mono', monospace; font-size: 15px; padding: 9px 13px;
|
||
border-radius: 2px; outline: none; transition: border-color 0.15s; letter-spacing: 0.02em;
|
||
}
|
||
#text-input:focus { border-color: var(--accent); }
|
||
#text-input::placeholder { color: var(--muted); }
|
||
.actions-row { display: flex; align-items: center; justify-content: space-between; }
|
||
.nav-btns { display: flex; gap: 8px; }
|
||
.hotkeys { font-size: 9px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.hotkey { display: flex; align-items: center; gap: 4px; }
|
||
.key { background: var(--surface2); border: 1px solid var(--border); border-radius: 2px; padding: 2px 5px; font-size: 9px; }
|
||
|
||
/* FOOTER */
|
||
footer {
|
||
background: var(--surface); border-top: 1px solid var(--border);
|
||
padding: 0 20px; height: 42px; display: flex; align-items: center;
|
||
justify-content: space-between; font-size: 10px; color: var(--muted);
|
||
}
|
||
.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--labeled); margin-right: 6px; animation: pulse 2s infinite; }
|
||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||
#current-filename { color: var(--muted); }
|
||
|
||
.toast {
|
||
position: fixed; bottom: 56px; right: 20px; background: var(--accent); color: #000;
|
||
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
|
||
padding: 9px 16px; border-radius: 2px; letter-spacing: 0.05em;
|
||
transform: translateY(16px); opacity: 0; transition: all 0.2s; z-index: 100; pointer-events: none;
|
||
}
|
||
.toast.show { transform: translateY(0); opacity: 1; }
|
||
.toast.error { background: var(--danger); color: #fff; }
|
||
|
||
::-webkit-scrollbar { width: 4px; }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- LOBBY -->
|
||
<div id="lobby">
|
||
<div style="text-align:center">
|
||
<div class="lobby-logo">OCR<span>//</span>Annotator</div>
|
||
<div class="lobby-sub" style="margin-top:6px">Выберите папку для разметки</div>
|
||
</div>
|
||
<div class="folder-grid" id="folder-grid">
|
||
<div class="no-folders">Загрузка...</div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--muted);text-align:center">
|
||
Каждый пользователь выбирает свою подпапку — разметка сохраняется независимо.<br>
|
||
При экспорте все подпапки объединяются в ZIP (картинки + labels.tsv).
|
||
</div>
|
||
</div>
|
||
|
||
<!-- APP -->
|
||
<div id="app">
|
||
<header>
|
||
<button class="back-btn" onclick="goLobby()">← Папки</button>
|
||
<div class="logo">OCR<span>//</span>Annotator</div>
|
||
<div class="subfolder-tag" id="subfolder-tag">—</div>
|
||
<div class="progress-wrap">
|
||
<div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0%"></div></div>
|
||
<div class="progress-text"><strong id="labeled-count">0</strong> / <span id="total-count">0</span> размечено<span id="recheck-text"></span></div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="btn btn-warn" id="autolabel-btn" onclick="startAutolabel()" title="Авто-разметить все неразмеченные картинки моделью TrOCR (требует перепроверки)">🤖 Авто-разметка</button>
|
||
<button class="btn btn-danger" onclick="confirmDelete()">🗑 Удалить</button>
|
||
<button class="btn" onclick="exportLabels()">⬇ Экспорт ZIP</button>
|
||
<button class="btn btn-accent" onclick="saveAndNext()">Сохранить →</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">
|
||
<span>Файлы</span>
|
||
<div class="filter-btns">
|
||
<button class="filter-btn active" onclick="setFilter('all',this)">Все</button>
|
||
<button class="filter-btn" onclick="setFilter('unlabeled',this)">Нет</button>
|
||
<button class="filter-btn" onclick="setFilter('auto',this)">🤖 Проверить</button>
|
||
<button class="filter-btn" onclick="setFilter('labeled',this)">Готовы</button>
|
||
</div>
|
||
</div>
|
||
<div class="image-list" id="image-list"></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="image-area" id="image-area">
|
||
<!-- zoom controls -->
|
||
<div class="zoom-controls">
|
||
<button class="zoom-btn" onclick="zoomStep(0.25)" title="Приблизить">+</button>
|
||
<div class="zoom-level" id="zoom-level">100%</div>
|
||
<button class="zoom-btn" onclick="zoomStep(-0.25)" title="Отдалить">−</button>
|
||
<button class="zoom-btn" onclick="resetZoom()" title="Сбросить" style="font-size:10px">⟳</button>
|
||
</div>
|
||
|
||
<div id="zoom-canvas">
|
||
<div class="img-frame" id="img-frame" style="display:none">
|
||
<img id="current-image" src="" alt="crop">
|
||
</div>
|
||
</div>
|
||
<div class="empty-state" id="empty-state">
|
||
<div class="icon">🗂</div>
|
||
<p>Нет изображений</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="input-area">
|
||
<div class="input-row">
|
||
<span class="input-label">Текст →</span>
|
||
<input type="text" id="text-input" placeholder="Введите распознанный текст..." autocomplete="off" spellcheck="false">
|
||
<span class="auto-hint" id="auto-hint" style="display:none">🤖 авто — перепроверьте</span>
|
||
</div>
|
||
<div class="actions-row">
|
||
<div class="nav-btns">
|
||
<button class="btn" onclick="navigate(-1)">← Назад</button>
|
||
<button class="btn" onclick="navigate(1)">Пропустить →</button>
|
||
</div>
|
||
<div class="hotkeys">
|
||
<div class="hotkey"><span class="key">Enter</span> сохранить и далее</div>
|
||
<div class="hotkey"><span class="key">Tab</span> пропустить</div>
|
||
<div class="hotkey"><span class="key">Alt+←/→</span> навигация</div>
|
||
<div class="hotkey"><span class="key">колесо</span> зум</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<footer>
|
||
<div><span class="status-dot"></span><span id="status-msg">Готов</span></div>
|
||
<div id="current-filename">—</div>
|
||
</footer>
|
||
</div>
|
||
|
||
|
||
<!-- Confirm delete dialog -->
|
||
<div class="confirm-overlay" id="confirm-overlay" style="display:none">
|
||
<div class="confirm-box">
|
||
<div class="confirm-icon">🗑</div>
|
||
<div class="confirm-title">Удалить изображение?</div>
|
||
<div class="confirm-sub" id="confirm-filename">—</div>
|
||
<div class="confirm-btns">
|
||
<button class="btn" id="btn-cancel-delete">Отмена</button>
|
||
<button class="btn btn-danger" id="btn-confirm-delete">Удалить навсегда</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
let images = [];
|
||
let currentIndex = 0;
|
||
let filter = 'all';
|
||
let currentSubfolder = null;
|
||
|
||
// ── Zoom state ─────────────────────────────────────────────────────────────
|
||
let scale = 1;
|
||
let translateX = 0;
|
||
let translateY = 0;
|
||
let isPanning = false;
|
||
let panStart = { x: 0, y: 0 };
|
||
|
||
function applyTransform() {
|
||
document.getElementById('zoom-canvas').style.transform =
|
||
`translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
||
document.getElementById('zoom-level').textContent = Math.round(scale * 100) + '%';
|
||
}
|
||
|
||
function zoomStep(delta) {
|
||
scale = Math.min(8, Math.max(0.25, scale + delta));
|
||
applyTransform();
|
||
}
|
||
|
||
function resetZoom() {
|
||
scale = 1; translateX = 0; translateY = 0;
|
||
applyTransform();
|
||
}
|
||
|
||
// Wheel zoom
|
||
document.addEventListener('wheel', e => {
|
||
const area = document.getElementById('image-area');
|
||
if (!area.contains(e.target)) return;
|
||
e.preventDefault();
|
||
const delta = e.deltaY < 0 ? 0.15 : -0.15;
|
||
scale = Math.min(8, Math.max(0.25, scale + delta));
|
||
applyTransform();
|
||
}, { passive: false });
|
||
|
||
// Pan with mouse drag
|
||
const imageArea = document.getElementById('image-area');
|
||
imageArea.addEventListener('mousedown', e => {
|
||
if (e.target.closest('.zoom-controls')) return;
|
||
isPanning = true;
|
||
panStart = { x: e.clientX - translateX, y: e.clientY - translateY };
|
||
});
|
||
window.addEventListener('mousemove', e => {
|
||
if (!isPanning) return;
|
||
translateX = e.clientX - panStart.x;
|
||
translateY = e.clientY - panStart.y;
|
||
applyTransform();
|
||
});
|
||
window.addEventListener('mouseup', () => { isPanning = false; });
|
||
|
||
// ── Lobby ──────────────────────────────────────────────────────────────────
|
||
async function loadLobby() {
|
||
const res = await fetch('/api/subfolders');
|
||
const subs = await res.json();
|
||
const grid = document.getElementById('folder-grid');
|
||
|
||
if (subs.length === 0) {
|
||
grid.innerHTML = '<div class="no-folders">Нет папок с изображениями.<br>Создайте подпапки внутри <code>data/images/</code></div>';
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = '';
|
||
subs.forEach(s => {
|
||
const auto = s.auto || 0;
|
||
const verified = s.labeled - auto;
|
||
const pct = s.total > 0 ? (verified / s.total * 100) : 0;
|
||
const autoTag = auto ? `<span style="color:var(--warn)">${auto} 🤖 на проверку</span>` : '';
|
||
const card = document.createElement('div');
|
||
card.className = 'folder-card';
|
||
card.innerHTML = `
|
||
<div class="folder-name">📁 ${s.name}</div>
|
||
<div class="folder-bar"><div class="folder-bar-fill" style="width:${pct}%"></div></div>
|
||
<div class="folder-stats">
|
||
<span class="done">${verified} готово</span>
|
||
${autoTag}
|
||
<span>${s.total} файлов</span>
|
||
</div>`;
|
||
card.onclick = () => openSubfolder(s.name);
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
async function openSubfolder(name) {
|
||
currentSubfolder = name;
|
||
document.getElementById('subfolder-tag').textContent = name;
|
||
document.getElementById('lobby').style.display = 'none';
|
||
document.getElementById('app').style.display = 'grid';
|
||
await loadImages();
|
||
startPolling();
|
||
resumeAutolabelIfRunning();
|
||
}
|
||
|
||
function goLobby() {
|
||
stopPolling();
|
||
clearInterval(autolabelTimer);
|
||
currentSubfolder = null;
|
||
document.getElementById('app').style.display = 'none';
|
||
document.getElementById('lobby').style.display = 'flex';
|
||
loadLobby();
|
||
}
|
||
|
||
// ── Polling for multi-user sync ────────────────────────────────────────────
|
||
let pollTimer = null;
|
||
|
||
function startPolling() {
|
||
pollTimer = setInterval(async () => {
|
||
if (!currentSubfolder) return;
|
||
const res = await fetch(`/api/images/${encodeURIComponent(currentSubfolder)}`);
|
||
const fresh = await res.json();
|
||
// Update labels without disrupting current selection
|
||
fresh.forEach(f => {
|
||
const img = images.find(i => i.filename === f.filename);
|
||
if (img) { img.labeled = f.labeled; img.text = f.text; }
|
||
});
|
||
updateProgress();
|
||
renderList();
|
||
}, 5000);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||
}
|
||
|
||
// ── Image list ─────────────────────────────────────────────────────────────
|
||
async function loadImages() {
|
||
const res = await fetch(`/api/images/${encodeURIComponent(currentSubfolder)}`);
|
||
images = await res.json();
|
||
updateProgress();
|
||
renderList();
|
||
const firstUnlabeled = images.findIndex(i => !i.labeled);
|
||
selectImage(firstUnlabeled >= 0 ? firstUnlabeled : 0);
|
||
}
|
||
|
||
function filteredImages() {
|
||
if (filter === 'labeled') return images.filter(i => i.labeled && !i.auto);
|
||
if (filter === 'unlabeled') return images.filter(i => !i.labeled);
|
||
if (filter === 'auto') return images.filter(i => i.labeled && i.auto);
|
||
return images;
|
||
}
|
||
|
||
function renderList() {
|
||
const list = document.getElementById('image-list');
|
||
const items = filteredImages();
|
||
list.innerHTML = '';
|
||
if (items.length === 0) {
|
||
list.innerHTML = '<div style="padding:14px;font-size:10px;color:var(--muted);text-align:center">Нет файлов</div>';
|
||
return;
|
||
}
|
||
items.forEach(img => {
|
||
const realIdx = images.indexOf(img);
|
||
const div = document.createElement('div');
|
||
const stateClass = img.labeled ? (img.auto ? ' auto' : ' labeled') : '';
|
||
div.className = 'image-item' + stateClass + (realIdx === currentIndex ? ' active' : '');
|
||
const badge = img.auto ? '<span class="item-badge">🤖</span>' : '';
|
||
div.innerHTML = `<div class="dot"></div><div class="item-text">${img.filename}</div>${badge}`;
|
||
div.onclick = () => selectImage(realIdx);
|
||
list.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function selectImage(index) {
|
||
if (!images.length) return;
|
||
index = Math.max(0, Math.min(index, images.length - 1));
|
||
currentIndex = index;
|
||
const img = images[index];
|
||
|
||
document.getElementById('img-frame').style.display = 'block';
|
||
document.getElementById('empty-state').style.display = 'none';
|
||
document.getElementById('current-image').src =
|
||
`/images/${encodeURIComponent(currentSubfolder)}/${encodeURIComponent(img.filename)}`;
|
||
document.getElementById('current-filename').textContent = img.filename;
|
||
document.getElementById('text-input').value = img.text || '';
|
||
document.getElementById('auto-hint').style.display = img.auto ? 'inline-block' : 'none';
|
||
document.getElementById('text-input').focus();
|
||
resetZoom();
|
||
renderList();
|
||
scrollActiveIntoView();
|
||
}
|
||
|
||
function scrollActiveIntoView() {
|
||
const active = document.querySelector('.image-item.active');
|
||
if (active) active.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
// ── Save / nav ─────────────────────────────────────────────────────────────
|
||
async function saveLabel() {
|
||
if (!images.length) return;
|
||
const img = images[currentIndex];
|
||
const text = document.getElementById('text-input').value.trim();
|
||
const res = await fetch(`/api/label/${encodeURIComponent(currentSubfolder)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: img.filename, text })
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
img.text = text;
|
||
img.labeled = text.length > 0;
|
||
img.auto = false; // manual save = human-verified
|
||
document.getElementById('auto-hint').style.display = 'none';
|
||
updateProgress();
|
||
showToast(text ? `✓ ${text}` : 'Метка удалена');
|
||
renderList();
|
||
}
|
||
}
|
||
|
||
async function saveAndNext() { await saveLabel(); navigate(1); }
|
||
|
||
function navigate(dir) {
|
||
const items = filteredImages();
|
||
const curFiltered = items.indexOf(images[currentIndex]);
|
||
const next = curFiltered + dir;
|
||
if (next >= 0 && next < items.length) {
|
||
selectImage(images.indexOf(items[next]));
|
||
} else if (dir > 0 && currentIndex < images.length - 1) {
|
||
selectImage(currentIndex + 1);
|
||
} else if (dir < 0 && currentIndex > 0) {
|
||
selectImage(currentIndex - 1);
|
||
}
|
||
}
|
||
|
||
function updateProgress() {
|
||
const total = images.length;
|
||
// «Готово» = только проверенные человеком; авто-метки ждут перепроверки.
|
||
const verified = images.filter(i => i.labeled && !i.auto).length;
|
||
const auto = images.filter(i => i.auto).length;
|
||
const pct = total > 0 ? (verified / total * 100) : 0;
|
||
document.getElementById('progress-fill').style.width = pct + '%';
|
||
document.getElementById('labeled-count').textContent = verified;
|
||
document.getElementById('total-count').textContent = total;
|
||
document.getElementById('recheck-text').textContent = auto ? ` · ${auto} 🤖 на проверку` : '';
|
||
document.getElementById('status-msg').textContent =
|
||
total > 0 ? `${total - verified} осталось` : 'Нет файлов';
|
||
}
|
||
|
||
function setFilter(f, btn) {
|
||
filter = f;
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
renderList();
|
||
}
|
||
|
||
function exportLabels() {
|
||
window.location.href = '/api/export';
|
||
showToast('Экспорт dataset.zip...');
|
||
}
|
||
|
||
// ── Auto-labeling (TrOCR) ────────────────────────────────────────────────────
|
||
let autolabelTimer = null;
|
||
|
||
async function startAutolabel() {
|
||
if (!currentSubfolder) return;
|
||
const remaining = images.filter(i => !i.labeled).length;
|
||
if (remaining === 0) { showToast('Нет неразмеченных изображений'); return; }
|
||
if (!confirm(
|
||
`Авто-разметить ${remaining} неразмеченных изображений моделью TrOCR?\n\n` +
|
||
`Метки сохранятся с пометкой «🤖 требует перепроверки» — их нужно вручную проверить.\n` +
|
||
`Первый запуск может занять время: модель скачивается из HuggingFace.`
|
||
)) return;
|
||
|
||
const btn = document.getElementById('autolabel-btn');
|
||
btn.disabled = true;
|
||
document.getElementById('status-msg').textContent = '🤖 Запуск авто-разметки...';
|
||
try {
|
||
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}`, { method: 'POST' });
|
||
if (!res.ok) {
|
||
const d = await res.json().catch(() => ({}));
|
||
showToast(d.error || 'Не удалось запустить', true);
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка: ' + err.message, true);
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
pollAutolabel();
|
||
}
|
||
|
||
function pollAutolabel() {
|
||
clearInterval(autolabelTimer);
|
||
document.getElementById('autolabel-btn').disabled = true;
|
||
autolabelTimer = setInterval(async () => {
|
||
if (!currentSubfolder) { clearInterval(autolabelTimer); return; }
|
||
let s;
|
||
try {
|
||
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}/status`);
|
||
s = await res.json();
|
||
} catch (e) { return; }
|
||
|
||
if (s.state === 'running') {
|
||
const pct = s.total ? Math.round(s.done / s.total * 100) : 0;
|
||
document.getElementById('status-msg').textContent =
|
||
`🤖 Авто-разметка: ${s.done}/${s.total} (${pct}%)`;
|
||
} else if (s.state === 'done') {
|
||
clearInterval(autolabelTimer);
|
||
document.getElementById('autolabel-btn').disabled = false;
|
||
showToast('Авто-разметка завершена — проверьте метки 🤖');
|
||
await loadImages();
|
||
} else if (s.state === 'error') {
|
||
clearInterval(autolabelTimer);
|
||
document.getElementById('autolabel-btn').disabled = false;
|
||
showToast('Ошибка авто-разметки: ' + (s.error || ''), true);
|
||
updateProgress();
|
||
} else {
|
||
clearInterval(autolabelTimer);
|
||
document.getElementById('autolabel-btn').disabled = false;
|
||
}
|
||
}, 1500);
|
||
}
|
||
|
||
async function resumeAutolabelIfRunning() {
|
||
if (!currentSubfolder) return;
|
||
try {
|
||
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}/status`);
|
||
const s = await res.json();
|
||
if (s.state === 'running') pollAutolabel();
|
||
} catch (e) {}
|
||
}
|
||
|
||
|
||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||
function confirmDelete() {
|
||
if (!images.length) return;
|
||
const img = images[currentIndex];
|
||
document.getElementById('confirm-filename').textContent = img.filename;
|
||
document.getElementById('confirm-overlay').style.display = 'flex';
|
||
}
|
||
|
||
function closeConfirm() {
|
||
document.getElementById('confirm-overlay').style.display = 'none';
|
||
try { document.getElementById('text-input').focus(); } catch(e) {}
|
||
}
|
||
|
||
async function doDelete() {
|
||
if (!images.length) return;
|
||
// Snapshot before closing (focus() can trigger blur events)
|
||
const subfolder = currentSubfolder;
|
||
const img = images[currentIndex];
|
||
const idx = currentIndex;
|
||
|
||
document.getElementById('confirm-overlay').style.display = 'none';
|
||
|
||
let res, data;
|
||
try {
|
||
res = await fetch(`/api/delete/${encodeURIComponent(subfolder)}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: img.filename })
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
data = await res.json();
|
||
} catch(err) {
|
||
console.error('Delete error:', err);
|
||
showToast(`Ошибка: ${err.message}`, true);
|
||
return;
|
||
}
|
||
if (data && data.ok) {
|
||
showToast(`Удалено: ${img.filename}`, true);
|
||
images.splice(idx, 1);
|
||
updateProgress();
|
||
if (images.length === 0) {
|
||
document.getElementById('img-frame').style.display = 'none';
|
||
document.getElementById('empty-state').style.display = 'flex';
|
||
document.getElementById('current-filename').textContent = '—';
|
||
document.getElementById('text-input').value = '';
|
||
renderList();
|
||
return;
|
||
}
|
||
selectImage(Math.min(idx, images.length - 1));
|
||
} else {
|
||
showToast('Ошибка удаления', true);
|
||
}
|
||
}
|
||
|
||
// Wire up dialog buttons
|
||
document.getElementById('btn-confirm-delete').addEventListener('click', doDelete);
|
||
document.getElementById('btn-cancel-delete').addEventListener('click', closeConfirm);
|
||
document.getElementById('confirm-overlay').addEventListener('click', e => {
|
||
if (e.target === document.getElementById('confirm-overlay')) closeConfirm();
|
||
});
|
||
|
||
function showToast(msg, isError = false) {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast show' + (isError ? ' error' : '');
|
||
clearTimeout(t._timer);
|
||
t._timer = setTimeout(() => t.className = 'toast', 2000);
|
||
}
|
||
|
||
// ── Hotkeys ────────────────────────────────────────────────────────────────
|
||
document.getElementById('text-input').addEventListener('keydown', async e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); await saveAndNext(); }
|
||
if (e.key === 'Tab') { e.preventDefault(); navigate(1); }
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
|
||
if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
|
||
if (e.key === 'Escape') closeConfirm();
|
||
if (e.key === 'Delete' && e.target.tagName !== 'INPUT') confirmDelete();
|
||
});
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
loadLobby();
|
||
</script>
|
||
</body>
|
||
</html>
|