1184 lines
36 KiB
HTML
1184 lines
36 KiB
HTML
<!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> Выбор класса | <kbd>Enter</kbd> Сохранить<br>
|
||
<kbd>Z</kbd> Отменить | <kbd>Space</kbd> Следующее<br>
|
||
<kbd>←</kbd> Предыдущее | <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> |