bone_2026/src/api/static/annotation.html

1184 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🦴 Bone Annotation Tool</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a202c;
min-height: 100vh;
display: flex;
flex-direction: column;
color: #e2e8f0;
}
.header {
background: #2d3748;
padding: 16px 32px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #4a5568;
flex-wrap: wrap;
gap: 12px;
}
.header h1 {
font-size: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a67d8;
transform: translateY(-2px);
}
.btn-success {
background: #48bb78;
color: white;
}
.btn-success:hover {
background: #38a169;
}
.btn-danger {
background: #fc8181;
color: white;
}
.btn-danger:hover {
background: #f56565;
}
.btn-secondary {
background: #4a5568;
color: white;
}
.btn-secondary:hover {
background: #2d3748;
}
.btn-upload {
background: #ed8936;
color: white;
position: relative;
overflow: hidden;
}
.btn-upload:hover {
background: #dd6b20;
}
.btn-upload input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.btn-upload.webkit {
/* Для WebKit браузеров */
position: relative;
}
.main {
display: flex;
flex: 1;
padding: 20px;
gap: 20px;
height: calc(100vh - 80px);
}
.canvas-container {
flex: 1;
background: #2d3748;
border-radius: 12px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-container .empty-state {
text-align: center;
color: #a0aec0;
}
.canvas-container .empty-state .icon {
font-size: 64px;
display: block;
margin-bottom: 16px;
}
#annotationCanvas {
max-width: 100%;
max-height: 100%;
cursor: crosshair;
display: none;
}
.sidebar {
width: 320px;
background: #2d3748;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
min-width: 280px;
}
.sidebar .info {
background: #1a202c;
padding: 12px 16px;
border-radius: 8px;
}
.sidebar .info .label {
font-size: 12px;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sidebar .info .value {
font-size: 18px;
font-weight: 700;
margin-top: 4px;
}
.sidebar .info .value .file-count {
font-size: 14px;
color: #a0aec0;
font-weight: 400;
}
.progress-bar {
width: 100%;
height: 8px;
background: #4a5568;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar .fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 4px;
transition: width 0.5s ease;
}
.dataset-info {
background: #1a202c;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
color: #a0aec0;
}
.dataset-info .name {
color: #e2e8f0;
font-weight: 600;
}
.tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.tools .btn {
font-size: 12px;
padding: 6px 12px;
}
.tools .btn.active {
background: #667eea;
}
.classes {
display: flex;
flex-direction: column;
gap: 6px;
}
.class-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #1a202c;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.class-item:hover {
background: #4a5568;
}
.class-item .color {
width: 16px;
height: 16px;
border-radius: 4px;
}
.class-item .name {
flex: 1;
font-size: 14px;
}
.class-item .shortcut {
font-size: 11px;
color: #a0aec0;
}
.class-item.selected {
border: 2px solid #667eea;
}
.actions {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.actions .btn {
flex: 1;
min-width: 80px;
font-size: 12px;
padding: 8px;
}
.shortcuts-hint {
font-size: 12px;
color: #a0aec0;
padding: 12px;
background: #1a202c;
border-radius: 8px;
line-height: 1.8;
}
.shortcuts-hint kbd {
background: #4a5568;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.nav-controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 8px;
}
.nav-controls .btn {
font-size: 18px;
padding: 4px 16px;
}
#imageCounter {
font-size: 14px;
color: #a0aec0;
min-width: 80px;
text-align: center;
}
.status-badge {
display: inline-block;
padding: 2px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.status-annotated {
background: #48bb78;
color: #22543d;
}
.status-pending {
background: #ed8936;
color: #7b341e;
}
/* Modal для загрузки */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #2d3748;
border-radius: 16px;
padding: 32px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
margin-bottom: 16px;
font-size: 22px;
}
.modal .drop-zone {
border: 3px dashed #4a5568;
border-radius: 12px;
padding: 40px;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
}
.modal .drop-zone:hover {
border-color: #667eea;
background: #1a202c;
}
.modal .drop-zone.drag-over {
border-color: #667eea;
background: #1a202c;
}
.modal .drop-zone .icon {
font-size: 48px;
display: block;
margin-bottom: 12px;
}
.modal .drop-zone p {
color: #a0aec0;
}
.modal .drop-zone input[type="file"] {
display: none;
}
.modal .file-list {
margin-top: 16px;
max-height: 200px;
overflow-y: auto;
}
.modal .file-item {
display: flex;
justify-content: space-between;
padding: 6px 12px;
background: #1a202c;
border-radius: 4px;
margin-bottom: 4px;
font-size: 13px;
}
.modal .file-item .size {
color: #a0aec0;
}
.modal .modal-actions {
display: flex;
gap: 12px;
margin-top: 16px;
justify-content: flex-end;
}
@media (max-width: 768px) {
.main {
flex-direction: column;
height: auto;
}
.sidebar {
width: 100%;
max-height: 400px;
min-width: unset;
}
.header {
padding: 12px 16px;
}
.header h1 {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="header">
<h1><a style="text-decoration: none;" href="/static/index.html">🦴</a> Bone Annotation Tool</h1>
<div class="header-actions">
<span id="statusBadge" class="status-badge status-pending">⏳ Нет данных</span>
<button class="btn btn-upload" id="uploadBtn">
📁 Загрузить датасет
<input type="file" id="fileInput" webkitdirectory directory multiple>
</button>
<button class="btn btn-secondary" onclick="exportDataset()">📦 Экспорт</button>
<button class="btn btn-danger" onclick="clearAll()">🗑️ Очистить</button>
</div>
</div>
<div class="main">
<!-- Canvas -->
<div class="canvas-container">
<div class="empty-state" id="emptyState">
<span class="icon">📂</span>
<h3>Нет загруженного датасета</h3>
<p style="color: #718096; margin-top: 8px;">Нажмите "Загрузить датасет" чтобы начать разметку</p>
</div>
<canvas id="annotationCanvas"></canvas>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Информация -->
<div class="info">
<div class="label">📊 Прогресс</div>
<div class="value" id="imageName">-</div>
<div class="progress-bar">
<div class="fill" id="progressFill" style="width: 0%"></div>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 4px; font-size: 12px; color: #a0aec0;">
<span id="progressText">0 / 0</span>
<span id="progressPercent">0%</span>
</div>
</div>
<!-- Информация о датасете -->
<div class="dataset-info" id="datasetInfo">
<div>📁 <span class="name" id="datasetName">Нет датасета</span></div>
<div style="font-size: 12px; margin-top: 4px;" id="datasetStats">0 файлов</div>
</div>
<!-- Навигация -->
<div class="nav-controls">
<button class="btn btn-secondary" onclick="prevImage()">◀</button>
<span id="imageCounter">0 / 0</span>
<button class="btn btn-secondary" onclick="nextImage()">▶</button>
</div>
<!-- Инструменты -->
<div>
<div style="font-size: 13px; color: #a0aec0; margin-bottom: 8px; font-weight: 600;">🛠️ Инструменты</div>
<div class="tools">
<button class="btn btn-primary active" data-tool="polygon" onclick="setTool('polygon')">📐 Полигон</button>
<button class="btn btn-secondary" data-tool="brush" onclick="setTool('brush')">🖌️ Кисть</button>
<button class="btn btn-secondary" data-tool="eraser" onclick="setTool('eraser')">🧹 Ластик</button>
<button class="btn btn-secondary" onclick="undoLast()">↩️ Отменить</button>
</div>
</div>
<!-- Классы -->
<div>
<div style="font-size: 13px; color: #a0aec0; margin-bottom: 8px; font-weight: 600;">🏷️ Классы</div>
<div class="classes" id="classList">
<div class="class-item selected" data-class="1" onclick="selectClass(1)">
<span class="color" style="background: #4299e1;"></span>
<span class="name">Кость</span>
<span class="shortcut">1</span>
</div>
<div class="class-item" data-class="2" onclick="selectClass(2)">
<span class="color" style="background: #48bb78;"></span>
<span class="name">Позвонок</span>
<span class="shortcut">2</span>
</div>
<div class="class-item" data-class="3" onclick="selectClass(3)">
<span class="color" style="background: #ed8936;"></span>
<span class="name">Фрагмент</span>
<span class="shortcut">3</span>
</div>
</div>
</div>
<!-- Действия -->
<div class="actions">
<button class="btn btn-success" onclick="saveAnnotation()">💾 Сохранить</button>
<button class="btn btn-danger" onclick="resetPolygon()">🔄 Сброс</button>
</div>
<!-- Shortcuts -->
<div class="shortcuts-hint">
<strong>⌨️ Горячие клавиши</strong><br>
<kbd>1-3</kbd> Выбор класса &nbsp;|&nbsp; <kbd>Enter</kbd> Сохранить<br>
<kbd>Z</kbd> Отменить &nbsp;|&nbsp; <kbd>Space</kbd> Следующее<br>
<kbd>←</kbd> Предыдущее &nbsp;|&nbsp; <kbd>→</kbd> Следующее
</div>
</div>
</div>
<!-- Modal для загрузки -->
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<h2>📁 Загрузка датасета</h2>
<p style="color: #a0aec0; margin-bottom: 16px;">Выберите папку с изображениями для разметки</p>
<div class="drop-zone" id="dropZone">
<span class="icon">📂</span>
<p><strong>Перетащите папку сюда</strong></p>
<p style="font-size: 13px;">или нажмите для выбора</p>
<input type="file" id="modalFileInput" webkitdirectory directory multiple>
</div>
<div class="file-list" id="fileList"></div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="loadDataset()">✅ Загрузить</button>
</div>
</div>
</div>
<script>
// ===== STATE =====
const state = {
sessionId: null,
images: [],
currentIndex: 0,
annotations: {},
imageFiles: [], // Для хранения File объектов
// Canvas
canvas: null,
ctx: null,
image: null,
imageDataUrl: null,
// Drawing
tool: 'polygon',
points: [],
isDrawing: false,
currentClass: 1,
// Undo
history: [],
// Dataset
datasetName: '',
isLoaded: false,
// Zoom/Pan
zoom: 1,
offsetX: 0,
offsetY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0,
};
// ===== DOM REFS =====
const canvas = document.getElementById('annotationCanvas');
const emptyState = document.getElementById('emptyState');
const uploadBtn = document.getElementById('uploadBtn');
const fileInput = document.getElementById('fileInput');
const modalFileInput = document.getElementById('modalFileInput');
const uploadModal = document.getElementById('uploadModal');
const dropZone = document.getElementById('dropZone');
const fileList = document.getElementById('fileList');
const datasetName = document.getElementById('datasetName');
const datasetStats = document.getElementById('datasetStats');
// ===== INIT =====
async function init() {
state.canvas = canvas;
state.ctx = canvas.getContext('2d');
// Создаем сессию
try {
const response = await fetch('/api/annotation/session/create', {
method: 'POST'
});
const data = await response.json();
state.sessionId = data.session_id;
console.log(`Session: ${state.sessionId}`);
} catch (e) {
console.warn('API не доступен, работаем локально');
}
// Canvas events
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel);
// Keyboard shortcuts
document.addEventListener('keydown', onKeyDown);
// File input events
fileInput.addEventListener('change', handleFileSelect);
modalFileInput.addEventListener('change', handleModalFileSelect);
// Drop zone
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFiles(files);
}
});
dropZone.addEventListener('click', () => {
modalFileInput.click();
});
// Upload button
uploadBtn.addEventListener('click', (e) => {
// Если это не клик по input
if (e.target === uploadBtn) {
openModal();
}
});
updateUI();
console.log('🦴 Bone Annotation Tool loaded');
}
// ===== MODAL =====
function openModal() {
uploadModal.classList.add('active');
fileList.innerHTML = '';
state.imageFiles = [];
}
function closeModal() {
uploadModal.classList.remove('active');
}
function handleModalFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
handleFiles(files);
}
}
function handleFiles(files) {
// Фильтруем изображения
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.dcm', '.dicom'];
const imageFiles = [];
for (const file of files) {
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (imageExtensions.includes(ext) || file.type.startsWith('image/')) {
imageFiles.push(file);
}
}
if (imageFiles.length === 0) {
alert('Не найдены изображения в выбранной папке');
return;
}
state.imageFiles = imageFiles;
renderFileList(imageFiles);
// Автоматически закрываем модал и загружаем
setTimeout(() => {
closeModal();
loadDatasetFromFiles(imageFiles);
}, 500);
}
function renderFileList(files) {
fileList.innerHTML = files.map((f, i) => `
<div class="file-item">
<span>${i + 1}. ${f.name}</span>
<span class="size">${(f.size / 1024).toFixed(1)} KB</span>
</div>
`).join('');
datasetStats.textContent = `${files.length} файлов`;
}
// ===== LOAD DATASET =====
function handleFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
handleFiles(files);
}
fileInput.value = '';
}
function loadDatasetFromFiles(files) {
state.images = files.map(f => URL.createObjectURL(f));
state.imageFiles = files;
state.datasetName = files.length > 0 ? files[0].webkitRelativePath.split('/')[0] || 'Датасет' : 'Датасет';
state.isLoaded = true;
state.currentIndex = 0;
state.annotations = {};
state.points = [];
datasetName.textContent = state.datasetName;
datasetStats.textContent = `${files.length} файлов`;
// Скрываем empty state, показываем canvas
emptyState.style.display = 'none';
canvas.style.display = 'block';
loadCurrentImage();
updateUI();
updateProgress();
// Отправляем на сервер
if (state.sessionId) {
const imagePaths = state.images;
fetch(`/api/annotation/session/${state.sessionId}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(imagePaths)
}).catch(e => console.warn('API load error:', e));
}
console.log(`✅ Загружено ${files.length} изображений`);
}
// ===== LOAD IMAGE =====
async function loadCurrentImage() {
if (state.images.length === 0) return;
const imageUrl = state.images[state.currentIndex];
const fileName = state.imageFiles[state.currentIndex]?.name || 'image';
document.getElementById('imageName').textContent = fileName;
document.getElementById('imageCounter').textContent =
`${state.currentIndex + 1} / ${state.images.length}`;
// Загружаем изображение
const img = new Image();
img.onload = () => {
state.image = img;
state.canvas.width = img.width;
state.canvas.height = img.height;
render();
checkAnnotation();
updateUI();
};
img.onerror = () => {
console.error('Failed to load image:', imageUrl);
};
img.src = imageUrl;
}
// ===== RENDER =====
function render() {
const ctx = state.ctx;
const canvas = state.canvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (state.image) {
ctx.drawImage(state.image, 0, 0);
}
// Рисуем существующую аннотацию
const annotation = state.annotations[state.currentIndex];
if (annotation) {
drawAnnotation(annotation);
}
// Рисуем текущий полигон
if (state.tool === 'polygon' && state.points.length > 0) {
drawPolygon(state.points, false);
}
}
function drawAnnotation(annotation) {
const ctx = state.ctx;
if (annotation.polygons) {
for (const polygon of annotation.polygons) {
drawPolygon(polygon.points, true);
}
}
}
function drawPolygon(points, isComplete) {
const ctx = state.ctx;
if (points.length < 2) return;
const colors = {
1: '#4299e1',
2: '#48bb78',
3: '#ed8936'
};
const color = colors[state.currentClass] || '#4299e1';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
if (isComplete) {
ctx.closePath();
ctx.fillStyle = color + '44';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
} else {
ctx.strokeStyle = color + 'cc';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
if (points.length > 0) {
const last = points[points.length - 1];
ctx.beginPath();
ctx.arc(last.x, last.y, 6, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
}
}
// ===== MOUSE EVENTS =====
function getMousePos(e) {
const rect = state.canvas.getBoundingClientRect();
const scaleX = state.canvas.width / rect.width;
const scaleY = state.canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
function onMouseDown(e) {
if (!state.isLoaded) return;
const pos = getMousePos(e);
if (state.tool === 'polygon') {
state.points.push({x: pos.x, y: pos.y});
render();
}
}
function onMouseMove(e) {}
function onMouseUp(e) {}
function onWheel(e) {
e.preventDefault();
}
// ===== TOOLS =====
function setTool(tool) {
state.tool = tool;
document.querySelectorAll('[data-tool]').forEach(el => {
el.classList.toggle('active', el.dataset.tool === tool);
});
if (tool !== 'polygon') {
state.points = [];
render();
}
}
function selectClass(classId) {
state.currentClass = classId;
document.querySelectorAll('.class-item').forEach(el => {
el.classList.toggle('selected', parseInt(el.dataset.class) === classId);
});
}
// ===== ANNOTATION =====
function resetPolygon() {
state.points = [];
render();
}
function undoLast() {
if (state.points.length > 0) {
state.points.pop();
render();
}
}
async function saveAnnotation() {
if (state.points.length < 3) {
alert('Добавьте минимум 3 точки для полигона');
return;
}
const annotation = {
image_id: state.currentIndex,
image_name: state.imageFiles[state.currentIndex]?.name || 'unknown',
polygons: [{
points: state.points,
label: getClassLabel(state.currentClass),
class_id: state.currentClass
}],
classification: {
condition: 'normal',
confidence: 1.0
},
created_at: new Date().toISOString()
};
state.annotations[state.currentIndex] = annotation;
state.points = [];
render();
updateProgress();
updateStatusBadge(true);
updateUI();
// Сохраняем на сервер
if (state.sessionId) {
try {
await fetch(`/api/annotation/session/${state.sessionId}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(annotation)
});
} catch (e) {
console.warn('API save error:', e);
}
}
// Скачиваем локально как JSON
downloadAnnotation(annotation);
}
function getClassLabel(classId) {
const labels = {1: 'bone', 2: 'vertebra', 3: 'fragment'};
return labels[classId] || 'bone';
}
function downloadAnnotation(annotation) {
const dataStr = JSON.stringify(annotation, null, 2);
const blob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `annotation_${state.currentIndex}.json`;
a.click();
URL.revokeObjectURL(url);
}
function checkAnnotation() {
const hasAnnotation = state.annotations[state.currentIndex] !== undefined;
updateStatusBadge(hasAnnotation);
}
function updateStatusBadge(annotated) {
const badge = document.getElementById('statusBadge');
if (!state.isLoaded) {
badge.textContent = '⏳ Нет данных';
badge.className = 'status-badge status-pending';
return;
}
if (annotated) {
badge.textContent = '✅ Размечено';
badge.className = 'status-badge status-annotated';
} else {
badge.textContent = '⏳ Не размечено';
badge.className = 'status-badge status-pending';
}
}
// ===== NAVIGATION =====
function updateProgress() {
const total = state.images.length;
const annotated = Object.keys(state.annotations).length;
const progress = total > 0 ? (annotated / total * 100) : 0;
document.getElementById('progressFill').style.width = `${progress}%`;
document.getElementById('progressText').textContent = `${annotated} / ${total}`;
document.getElementById('progressPercent').textContent = `${Math.round(progress)}%`;
}
function updateUI() {
const hasData = state.images.length > 0;
document.getElementById('imageCounter').textContent =
hasData ? `${state.currentIndex + 1} / ${state.images.length}` : '0 / 0';
document.getElementById('imageName').textContent =
hasData ? (state.imageFiles[state.currentIndex]?.name || 'image') : '-';
updateProgress();
}
async function nextImage() {
if (state.currentIndex < state.images.length - 1) {
state.currentIndex++;
await loadCurrentImage();
}
}
async function prevImage() {
if (state.currentIndex > 0) {
state.currentIndex--;
await loadCurrentImage();
}
}
// ===== EXPORT =====
async function exportDataset() {
if (Object.keys(state.annotations).length === 0) {
alert('Нет аннотаций для экспорта');
return;
}
// Экспорт в JSON
const exportData = {
dataset_name: state.datasetName,
total_images: state.images.length,
annotated: Object.keys(state.annotations).length,
annotations: state.annotations,
classes: {
1: 'bone',
2: 'vertebra',
3: 'fragment'
},
exported_at: new Date().toISOString()
};
const dataStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dataset_${state.datasetName}_annotations.json`;
a.click();
URL.revokeObjectURL(url);
if (state.sessionId) {
try {
await fetch(`/api/annotation/session/${state.sessionId}/export?format=coco`, {
method: 'POST'
});
} catch (e) {
console.warn('API export error:', e);
}
}
}
function clearAll() {
if (!confirm('Очистить все аннотации?')) return;
state.annotations = {};
state.points = [];
state.images = [];
state.imageFiles = [];
state.isLoaded = false;
state.currentIndex = 0;
emptyState.style.display = 'flex';
canvas.style.display = 'none';
datasetName.textContent = 'Нет датасета';
datasetStats.textContent = '0 файлов';
updateUI();
updateStatusBadge(false);
render();
}
// ===== KEYBOARD =====
function onKeyDown(e) {
if (e.key >= '1' && e.key <= '3') {
selectClass(parseInt(e.key));
return;
}
if (e.key === 'Enter') {
saveAnnotation();
return;
}
if (e.key === 'z' || e.key === 'Z') {
undoLast();
return;
}
if (e.key === ' ' || e.key === 'Space') {
e.preventDefault();
nextImage();
return;
}
if (e.key === 'ArrowRight') {
nextImage();
return;
}
if (e.key === 'ArrowLeft') {
prevImage();
return;
}
}
// ===== START =====
init();
console.log('🦴 Bone Annotation Tool loaded');
</script>
</body>
</html>