annotator / index.html
studentscolab's picture
Upload 6 files
ea32c8b verified
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Annotator YOLO PWA</title>
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="manifest.json" />
<style>
:root {
--bg: #020617;
--panel: #111827;
--panel-2: #1f2937;
--panel-3: #0f172a;
--text: #e5e7eb;
--muted: #94a3b8;
--border: #334155;
--accent: #22c55e;
--danger: #ef4444;
--active: #2563eb;
--warning: #f59e0b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.app {
display: grid;
grid-template-columns: 380px 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100vh;
}
h1 {
margin: 0 0 8px;
font-size: 22px;
}
.hint {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
}
.panel {
background: rgba(255,255,255,0.02);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
margin-bottom: 14px;
}
.panel h2 {
margin: 0 0 10px;
font-size: 15px;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.stat {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
}
.stat .label { font-size: 12px; color: var(--muted); }
.stat .value { font-size: 19px; font-weight: 700; margin-top: 2px; }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: end;
padding: 12px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
color: var(--muted);
}
input[type="text"], input[type="file"], input[type="range"], input[type="color"], select, button {
font: inherit;
}
input[type="text"], select {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
min-width: 140px;
}
input[type="range"] { width: 170px; }
button {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
cursor: pointer;
}
button:hover { filter: brightness(1.08); }
button.primary { background: #14532d; border-color: #166534; }
button.secondary { background: #1e3a8a; border-color: #1d4ed8; }
button.danger { background: #7f1d1d; border-color: #991b1b; }
button.warning { background: #78350f; border-color: #b45309; }
.canvas-wrap {
flex: 1;
overflow: auto;
background:
linear-gradient(45deg, #0b1220 25%, transparent 25%),
linear-gradient(-45deg, #0b1220 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #0b1220 75%),
linear-gradient(-45deg, transparent 75%, #0b1220 75%);
background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
padding: 16px;
min-height: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.canvas-stage {
display: inline-block;
line-height: 0;
}
canvas {
display: block;
background: #000;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
transform-origin: top left;
max-width: none;
cursor: crosshair;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow: auto;
}
.item {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
display: grid;
gap: 8px;
}
.item.selected { outline: 2px solid var(--accent); }
.item.active { outline: 2px solid var(--active); }
.item-top {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.muted { color: var(--muted); font-size: 12px; }
.label-chip {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
padding: 4px 10px;
border: 1px solid var(--border);
font-size: 12px;
width: fit-content;
background: rgba(255,255,255,0.04);
}
.color-dot {
width: 12px;
height: 12px;
border-radius: 999px;
display: inline-block;
border: 1px solid rgba(255,255,255,0.2);
}
.inline-input {
min-width: 0;
width: 100%;
background: var(--panel-3);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 8px 10px;
}
.inline-color {
width: 42px;
height: 38px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
padding: 2px;
}
.inline-number {
width: 100%;
background: var(--panel-3);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 8px 10px;
}
.check-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
padding: 4px 0;
}
.check-row input[type="checkbox"] {
width: 16px;
height: 16px;
}
.footer-note {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
margin-top: 10px;
}
.zoom-value {
min-width: 54px;
text-align: center;
padding: 10px 0;
font-weight: 600;
}
.status {
min-height: 28px;
border-top: 1px solid var(--border);
background: #0b1220;
color: var(--muted);
padding: 8px 16px;
font-size: 13px;
}
code {
background: rgba(255,255,255,0.08);
padding: 2px 6px;
border-radius: 6px;
}
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
.sidebar {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<h1>Annotator YOLO</h1>
<div class="hint">
Zdefiniuj etykiety, wybierz aktywną klasę, oznaczaj obiekty i poprawiaj boxy przez przeciąganie.
W trybie <strong>Zaznaczanie</strong> możesz przesuwać box lub łapać za uchwyty, żeby zmienić jego rozmiar.
</div>
<div class="panel">
<h2>Podsumowanie</h2>
<div class="stats">
<div class="stat"><div class="label">Obrazki</div><div class="value" id="imageCountBox">0</div></div>
<div class="stat"><div class="label">Adnotacje</div><div class="value" id="countBox">0</div></div>
<div class="stat"><div class="label">Klasy</div><div class="value" id="classCountBox">0</div></div>
</div>
</div>
<div class="panel">
<h2>Etykiety</h2>
<div class="row" style="margin-bottom:10px;">
<input id="newLabelInput" class="inline-input" type="text" placeholder="Nowa etykieta, np. samochód" />
<input id="newLabelColor" class="inline-color" type="color" value="#22c55e" />
<button id="addLabelBtn">Dodaj</button>
</div>
<div id="labelList" class="list"></div>
</div>
<div class="panel">
<h2>Ustawienia</h2>
<div class="row" style="margin-bottom:10px;">
<button id="exportSettingsBtn" class="secondary">Eksport ustawień JSON</button>
<button id="importSettingsBtn">Import ustawień</button>
<input id="settingsFileInput" type="file" accept=".json,application/json" hidden />
</div>
<div class="row" style="margin-bottom:10px;">
<div style="flex:1; min-width:140px;">
<div class="muted" style="margin-bottom:4px;">Minimalny box (px)</div>
<input id="minBoxSizeInput" class="inline-number" type="number" min="1" max="200" step="1" value="8" />
</div>
<div style="flex:1; min-width:140px;">
<div class="muted" style="margin-bottom:4px;">Krok zoomu (%)</div>
<input id="zoomStepInput" class="inline-number" type="number" min="1" max="100" step="1" value="10" />
</div>
</div>
<div class="row" style="margin-bottom:6px;">
<label class="check-row"><input id="fitOnLoadInput" type="checkbox" checked /> Dopasuj obraz po wczytaniu</label>
</div>
<div class="row" style="margin-bottom:6px;">
<label class="check-row"><input id="autoSelectLabelInput" type="checkbox" checked /> Po imporcie ustaw aktywną pierwszą etykietę</label>
</div>
<div class="muted">Te ustawienia oraz lista etykiet mogą być zapisane do pliku JSON i wczytane ponownie.</div>
</div>
<div class="panel">
<h2>Lista obrazków</h2>
<div id="imageList" class="list"></div>
</div>
<div class="panel">
<h2>Adnotacje bieżącego obrazka</h2>
<div id="annotationList" class="list"></div>
</div>
<div class="footer-note">
Eksport YOLO tworzy plik <code>classes.txt</code>, plik <code>data.yaml</code> oraz pliki etykiet <code>.txt</code>
z wierszami <code>class_id x_center y_center width height</code>. Kolejność klas pochodzi z listy etykiet po lewej.
</div>
</aside>
<main class="main">
<div class="toolbar">
<div class="field">
<label for="imageInput">Obrazki</label>
<input id="imageInput" type="file" accept="image/*" multiple />
</div>
<div class="field">
<label for="toolSelect">Tryb</label>
<select id="toolSelect">
<option value="draw">Rysowanie</option>
<option value="select">Zaznaczanie</option>
</select>
</div>
<div class="field">
<label for="currentLabelSelect">Aktywna etykieta</label>
<select id="currentLabelSelect"></select>
</div>
<button id="applyLabelBtn">Przypisz etykietę zaznaczonej</button>
<button id="prevBtn">◀ Poprzedni</button>
<button id="nextBtn">Następny ▶</button>
<div class="field">
<label for="zoomRange">Zoom</label>
<input id="zoomRange" type="range" min="10" max="400" step="10" value="100" />
</div>
<div class="zoom-value" id="zoomValue">100%</div>
<button id="zoomOutBtn">-</button>
<button id="zoomInBtn">+</button>
<button id="fitBtn">Dopasuj</button>
<button id="actualSizeBtn">1:1</button>
<button id="undoBtn">Cofnij</button>
<button id="deleteBtn" class="danger">Usuń zaznaczoną</button>
<button id="clearCurrentBtn" class="warning">Wyczyść bieżący</button>
<button id="clearAllBtn" class="danger">Wyczyść wszystko</button>
<button id="exportCurrentBtn" class="secondary">Eksport YOLO: bieżący</button>
<button id="exportAllBtn" class="primary">Eksport YOLO: cały zestaw</button>
</div>
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas-stage" id="canvasStage">
<canvas id="canvas" width="960" height="540"></canvas>
</div>
</div>
<div class="status" id="statusBar">Gotowe.</div>
</main>
</div>
<script>
const HANDLE_ORDER = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const HANDLE_CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const canvasWrap = document.getElementById("canvasWrap");
const canvasStage = document.getElementById("canvasStage");
const imageInput = document.getElementById("imageInput");
const toolSelect = document.getElementById("toolSelect");
const currentLabelSelect = document.getElementById("currentLabelSelect");
const applyLabelBtn = document.getElementById("applyLabelBtn");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const zoomRange = document.getElementById("zoomRange");
const zoomValue = document.getElementById("zoomValue");
const zoomOutBtn = document.getElementById("zoomOutBtn");
const zoomInBtn = document.getElementById("zoomInBtn");
const fitBtn = document.getElementById("fitBtn");
const actualSizeBtn = document.getElementById("actualSizeBtn");
const undoBtn = document.getElementById("undoBtn");
const deleteBtn = document.getElementById("deleteBtn");
const clearCurrentBtn = document.getElementById("clearCurrentBtn");
const clearAllBtn = document.getElementById("clearAllBtn");
const exportCurrentBtn = document.getElementById("exportCurrentBtn");
const exportAllBtn = document.getElementById("exportAllBtn");
const imageList = document.getElementById("imageList");
const annotationList = document.getElementById("annotationList");
const labelList = document.getElementById("labelList");
const newLabelInput = document.getElementById("newLabelInput");
const newLabelColor = document.getElementById("newLabelColor");
const addLabelBtn = document.getElementById("addLabelBtn");
const exportSettingsBtn = document.getElementById("exportSettingsBtn");
const importSettingsBtn = document.getElementById("importSettingsBtn");
const settingsFileInput = document.getElementById("settingsFileInput");
const minBoxSizeInput = document.getElementById("minBoxSizeInput");
const zoomStepInput = document.getElementById("zoomStepInput");
const fitOnLoadInput = document.getElementById("fitOnLoadInput");
const autoSelectLabelInput = document.getElementById("autoSelectLabelInput");
const imageCountBox = document.getElementById("imageCountBox");
const countBox = document.getElementById("countBox");
const classCountBox = document.getElementById("classCountBox");
const statusBar = document.getElementById("statusBar");
const state = {
images: [],
currentImageIndex: -1,
labels: [],
currentLabelId: null,
selectedId: null,
drawing: false,
draft: null,
zoom: 1,
interaction: null,
pointerDown: false,
settings: null
};
function uid() {
return Math.random().toString(36).slice(2, 10);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function round(value, digits = 6) {
return Number(value.toFixed(digits));
}
function defaultSettings() {
return {
schemaVersion: 1,
minBoxSize: 8,
zoomStep: 0.1,
fitOnImageLoad: true,
autoSelectFirstLabelOnImport: true
};
}
function sanitizeSettings(input = {}) {
const defaults = defaultSettings();
const minBoxSize = Number(input.minBoxSize);
const zoomStep = Number(input.zoomStep);
return {
schemaVersion: 1,
minBoxSize: Number.isFinite(minBoxSize) ? clamp(Math.round(minBoxSize), 1, 200) : defaults.minBoxSize,
zoomStep: Number.isFinite(zoomStep) ? clamp(zoomStep, 0.01, 1) : defaults.zoomStep,
fitOnImageLoad: typeof input.fitOnImageLoad === "boolean" ? input.fitOnImageLoad : defaults.fitOnImageLoad,
autoSelectFirstLabelOnImport: typeof input.autoSelectFirstLabelOnImport === "boolean" ? input.autoSelectFirstLabelOnImport : defaults.autoSelectFirstLabelOnImport
};
}
function ensureSettings() {
if (!state.settings) state.settings = defaultSettings();
state.settings = sanitizeSettings(state.settings);
}
function syncSettingsForm() {
ensureSettings();
minBoxSizeInput.value = String(state.settings.minBoxSize);
zoomStepInput.value = String(Math.round(state.settings.zoomStep * 100));
fitOnLoadInput.checked = !!state.settings.fitOnImageLoad;
autoSelectLabelInput.checked = !!state.settings.autoSelectFirstLabelOnImport;
}
function applySettingsFromForm() {
ensureSettings();
state.settings = sanitizeSettings({
minBoxSize: minBoxSizeInput.value,
zoomStep: Number(zoomStepInput.value) / 100,
fitOnImageLoad: fitOnLoadInput.checked,
autoSelectFirstLabelOnImport: autoSelectLabelInput.checked
});
syncSettingsForm();
setStatus("Zaktualizowano ustawienia aplikacji.");
}
function getMinBoxSize() {
ensureSettings();
return state.settings.minBoxSize;
}
function getZoomStep() {
ensureSettings();
return state.settings.zoomStep;
}
function setStatus(message) {
statusBar.textContent = message;
}
function currentImage() {
return state.images[state.currentImageIndex] || null;
}
function currentAnnotations() {
return currentImage()?.annotations || [];
}
function getLabelById(id) {
return state.labels.find(label => label.id === id) || null;
}
function selectedAnnotation() {
return currentAnnotations().find(ann => ann.id === state.selectedId) || null;
}
function ensureDefaultLabel() {
ensureSettings();
if (!state.labels.length) {
const label = { id: uid(), name: "obiekt", color: "#22c55e" };
state.labels.push(label);
state.currentLabelId = label.id;
}
}
function addLabel(name, color = "#22c55e") {
const cleaned = (name || "").trim();
if (!cleaned) {
setStatus("Podaj nazwę etykiety.");
return;
}
const duplicate = state.labels.find(label => label.name.toLowerCase() === cleaned.toLowerCase());
if (duplicate) {
state.currentLabelId = duplicate.id;
render();
setStatus(`Etykieta "${cleaned}" już istnieje.`);
return;
}
const label = { id: uid(), name: cleaned, color };
state.labels.push(label);
state.currentLabelId = label.id;
render();
setStatus(`Dodano etykietę: ${cleaned}`);
}
function renameLabel(labelId, nextName) {
const label = getLabelById(labelId);
if (!label) return;
const cleaned = (nextName || "").trim();
if (!cleaned) {
setStatus("Nazwa etykiety nie może być pusta.");
render();
return;
}
const duplicate = state.labels.find(item => item.id !== labelId && item.name.toLowerCase() === cleaned.toLowerCase());
if (duplicate) {
setStatus(`Etykieta "${cleaned}" już istnieje.`);
render();
return;
}
label.name = cleaned;
render();
setStatus(`Zmieniono nazwę etykiety na: ${cleaned}`);
}
function updateLabelColor(labelId, color) {
const label = getLabelById(labelId);
if (!label) return;
label.color = color;
render();
setStatus(`Zmieniono kolor etykiety: ${label.name}`);
}
function countLabelUsage(labelId) {
let count = 0;
for (const image of state.images) {
for (const ann of image.annotations) {
if (ann.labelId === labelId) count += 1;
}
}
return count;
}
function removeLabel(labelId) {
if (state.labels.length <= 1) {
setStatus("Musi pozostać co najmniej jedna etykieta.");
return;
}
const usage = countLabelUsage(labelId);
const label = getLabelById(labelId);
if (usage > 0) {
setStatus(`Nie można usunąć etykiety "${label?.name}" — jest używana w ${usage} adnotacjach.`);
return;
}
state.labels = state.labels.filter(label => label.id !== labelId);
if (state.currentLabelId === labelId) {
state.currentLabelId = state.labels[0]?.id || null;
}
render();
setStatus("Usunięto etykietę.");
}
function setZoom(nextZoom) {
state.zoom = clamp(nextZoom, 0.1, 4);
zoomRange.value = String(Math.round(state.zoom * 100));
zoomValue.textContent = `${Math.round(state.zoom * 100)}%`;
updateCanvasScale();
}
function updateCanvasScale() {
const scaledWidth = canvas.width * state.zoom;
const scaledHeight = canvas.height * state.zoom;
canvas.style.width = `${scaledWidth}px`;
canvas.style.height = `${scaledHeight}px`;
canvasStage.style.width = `${scaledWidth}px`;
canvasStage.style.height = `${scaledHeight}px`;
}
function fitToViewport() {
const image = currentImage();
if (!image) {
setZoom(1);
return;
}
const availableWidth = Math.max(100, canvasWrap.clientWidth - 32);
const availableHeight = Math.max(100, canvasWrap.clientHeight - 32);
const zoomX = availableWidth / canvas.width;
const zoomY = availableHeight / canvas.height;
setZoom(Math.min(zoomX, zoomY, 1));
}
function getMousePos(event) {
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * (canvas.width / rect.width),
y: (event.clientY - rect.top) * (canvas.height / rect.height)
};
}
function normalizeRect(x1, y1, x2, y2) {
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1)
};
}
function clampRect(rect) {
const x = clamp(rect.x, 0, canvas.width);
const y = clamp(rect.y, 0, canvas.height);
const width = clamp(rect.width, 1, canvas.width - x);
const height = clamp(rect.height, 1, canvas.height - y);
return { x, y, width, height };
}
function pointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
function getHandleRadius() {
return Math.max(6, 8 / state.zoom);
}
function getHandlePositions(rect) {
const x1 = rect.x;
const y1 = rect.y;
const x2 = rect.x + rect.width;
const y2 = rect.y + rect.height;
const cx = rect.x + rect.width / 2;
const cy = rect.y + rect.height / 2;
return {
nw: { x: x1, y: y1 },
n: { x: cx, y: y1 },
ne: { x: x2, y: y1 },
e: { x: x2, y: cy },
se: { x: x2, y: y2 },
s: { x: cx, y: y2 },
sw: { x: x1, y: y2 },
w: { x: x1, y: cy }
};
}
function hitHandle(annotation, x, y) {
const radius = getHandleRadius();
const positions = getHandlePositions(annotation);
for (const key of HANDLE_ORDER) {
const point = positions[key];
if (Math.abs(point.x - x) <= radius && Math.abs(point.y - y) <= radius) {
return key;
}
}
return null;
}
function findTopAnnotation(x, y) {
const annotations = [...currentAnnotations()].reverse();
return annotations.find(ann => pointInRect(x, y, ann)) || null;
}
function updateCursor(event) {
if (toolSelect.value === "draw") {
canvas.style.cursor = "crosshair";
return;
}
if (state.interaction) {
if (state.interaction.type === "resize") {
canvas.style.cursor = HANDLE_CURSOR[state.interaction.handle] || "move";
} else {
canvas.style.cursor = "move";
}
return;
}
const image = currentImage();
if (!image) {
canvas.style.cursor = "default";
return;
}
const { x, y } = getMousePos(event);
const selected = selectedAnnotation();
if (selected) {
const handle = hitHandle(selected, x, y);
if (handle) {
canvas.style.cursor = HANDLE_CURSOR[handle] || "pointer";
return;
}
}
const ann = findTopAnnotation(x, y);
canvas.style.cursor = ann ? "move" : "default";
}
async function loadImageFromFile(file) {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = objectUrl;
});
return {
id: uid(),
name: file.name,
file,
objectUrl,
img,
width: img.naturalWidth,
height: img.naturalHeight,
annotations: []
};
}
async function addImages(files) {
const fileArray = Array.from(files || []).filter(file => file.type.startsWith("image/"));
if (!fileArray.length) return;
setStatus(`Wczytywanie ${fileArray.length} obrazków...`);
const loaded = [];
for (const file of fileArray) {
try {
loaded.push(await loadImageFromFile(file));
} catch (error) {
console.error("Nie udało się wczytać pliku:", file.name, error);
}
}
state.images.push(...loaded);
if (state.currentImageIndex === -1 && state.images.length) {
switchImage(0, state.settings?.fitOnImageLoad ?? true);
} else {
render();
}
setStatus(`Dodano ${loaded.length} obrazków.`);
}
function switchImage(index, autoFit = false) {
if (index < 0 || index >= state.images.length) return;
state.currentImageIndex = index;
state.selectedId = null;
state.draft = null;
state.interaction = null;
const image = currentImage();
canvas.width = image.width;
canvas.height = image.height;
render();
if (autoFit) {
requestAnimationFrame(fitToViewport);
} else {
updateCanvasScale();
}
setStatus(`Wybrano obrazek: ${image.name}`);
}
function removeImage(index) {
if (index < 0 || index >= state.images.length) return;
const [removed] = state.images.splice(index, 1);
if (removed?.objectUrl) URL.revokeObjectURL(removed.objectUrl);
if (!state.images.length) {
state.currentImageIndex = -1;
state.selectedId = null;
state.draft = null;
state.interaction = null;
canvas.width = 960;
canvas.height = 540;
render();
updateCanvasScale();
setStatus("Usunięto wszystkie obrazki.");
return;
}
switchImage(Math.min(index, state.images.length - 1), state.settings?.fitOnImageLoad ?? true);
}
function drawEmptyState() {
ctx.fillStyle = "#0f172a";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#94a3b8";
ctx.textAlign = "center";
ctx.font = "24px system-ui";
ctx.fillText("Wgraj obrazki, aby rozpocząć", canvas.width / 2, canvas.height / 2 - 10);
ctx.font = "16px system-ui";
ctx.fillText("Zdefiniuj etykiety i rysuj boxy na wybranym obrazku", canvas.width / 2, canvas.height / 2 + 24);
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const image = currentImage();
if (image) {
ctx.drawImage(image.img, 0, 0, canvas.width, canvas.height);
} else {
drawEmptyState();
}
for (const ann of currentAnnotations()) {
drawAnnotation(ann, ann.id === state.selectedId);
}
if (state.draft) {
drawAnnotation(state.draft, false, true);
}
renderLabelList();
renderCurrentLabelSelect();
renderImageList();
renderAnnotationList();
updateStats();
}
function drawAnnotation(annotation, selected = false, draft = false) {
const label = getLabelById(annotation.labelId) || { name: annotation.labelName || "?", color: annotation.color || "#22c55e" };
const { x, y, width, height } = annotation;
ctx.save();
ctx.lineWidth = selected ? 3 : 2;
ctx.strokeStyle = label.color;
ctx.fillStyle = label.color;
if (draft) ctx.setLineDash([8, 6]);
ctx.strokeRect(x, y, width, height);
const text = label.name || "obiekt";
ctx.font = `${Math.max(12, 14 / Math.min(state.zoom, 1.5))}px system-ui`;
const textWidth = ctx.measureText(text).width;
const tagWidth = textWidth + 18;
const tagHeight = 24;
const tagX = x;
const tagY = Math.max(0, y - tagHeight);
ctx.fillRect(tagX, tagY, tagWidth, tagHeight);
ctx.fillStyle = "#08110a";
ctx.fillText(text, tagX + 9, tagY + 16);
if (selected) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = "#ffffff";
ctx.strokeRect(x - 2, y - 2, width + 4, height + 4);
drawHandles(annotation, label.color);
}
ctx.restore();
}
function drawHandles(annotation, color) {
const radius = getHandleRadius();
const positions = getHandlePositions(annotation);
ctx.save();
for (const key of HANDLE_ORDER) {
const point = positions[key];
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(point.x - radius, point.y - radius, radius * 2, radius * 2);
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
function renderLabelList() {
labelList.innerHTML = "";
if (!state.labels.length) {
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Brak etykiet.";
labelList.appendChild(empty);
return;
}
for (const label of state.labels) {
const item = document.createElement("div");
item.className = "item" + (label.id === state.currentLabelId ? " active" : "");
const top = document.createElement("div");
top.className = "item-top";
const chip = document.createElement("div");
chip.className = "label-chip";
const dot = document.createElement("span");
dot.className = "color-dot";
dot.style.background = label.color;
const name = document.createElement("span");
name.textContent = label.name;
chip.append(dot, name);
const usage = document.createElement("div");
usage.className = "muted";
usage.textContent = `${countLabelUsage(label.id)} użyć`;
top.append(chip, usage);
const row1 = document.createElement("div");
row1.className = "row";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.value = label.name;
nameInput.className = "inline-input";
nameInput.addEventListener("change", () => renameLabel(label.id, nameInput.value));
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.value = label.color;
colorInput.className = "inline-color";
colorInput.addEventListener("input", () => updateLabelColor(label.id, colorInput.value));
row1.append(nameInput, colorInput);
const row2 = document.createElement("div");
row2.className = "row";
const selectBtn = document.createElement("button");
selectBtn.textContent = label.id === state.currentLabelId ? "Aktywna" : "Wybierz";
selectBtn.addEventListener("click", () => {
state.currentLabelId = label.id;
render();
setStatus(`Aktywna etykieta: ${label.name}`);
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "danger";
deleteBtn.textContent = "Usuń";
deleteBtn.addEventListener("click", () => removeLabel(label.id));
row2.append(selectBtn, deleteBtn);
item.append(top, row1, row2);
labelList.appendChild(item);
}
}
function renderCurrentLabelSelect() {
currentLabelSelect.innerHTML = "";
for (const label of state.labels) {
const option = document.createElement("option");
option.value = label.id;
option.textContent = label.name;
if (label.id === state.currentLabelId) option.selected = true;
currentLabelSelect.appendChild(option);
}
currentLabelSelect.disabled = !state.labels.length;
}
function renderImageList() {
imageList.innerHTML = "";
if (!state.images.length) {
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Brak obrazków.";
imageList.appendChild(empty);
return;
}
state.images.forEach((image, index) => {
const item = document.createElement("div");
item.className = "item" + (index === state.currentImageIndex ? " active" : "");
const top = document.createElement("div");
top.className = "item-top";
const title = document.createElement("strong");
title.style.fontSize = "13px";
title.style.wordBreak = "break-word";
title.textContent = image.name;
const nr = document.createElement("div");
nr.className = "muted";
nr.textContent = `${index + 1}/${state.images.length}`;
top.append(title, nr);
const info = document.createElement("div");
info.className = "muted";
info.textContent = `${image.width} × ${image.height}${image.annotations.length} adnot.`;
const row = document.createElement("div");
row.className = "row";
const openBtn = document.createElement("button");
openBtn.textContent = "Otwórz";
openBtn.addEventListener("click", () => switchImage(index, state.settings?.fitOnImageLoad ?? true));
const removeBtn = document.createElement("button");
removeBtn.className = "danger";
removeBtn.textContent = "Usuń";
removeBtn.addEventListener("click", () => removeImage(index));
row.append(openBtn, removeBtn);
item.append(top, info, row);
imageList.appendChild(item);
});
}
function renderAnnotationList() {
annotationList.innerHTML = "";
const annotations = currentAnnotations();
if (!annotations.length) {
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Brak adnotacji dla bieżącego obrazka.";
annotationList.appendChild(empty);
return;
}
annotations.forEach((ann, index) => {
const label = getLabelById(ann.labelId);
const item = document.createElement("div");
item.className = "item" + (ann.id === state.selectedId ? " selected" : "");
const top = document.createElement("div");
top.className = "item-top";
const chip = document.createElement("div");
chip.className = "label-chip";
const dot = document.createElement("span");
dot.className = "color-dot";
dot.style.background = label?.color || "#22c55e";
const name = document.createElement("span");
name.textContent = label?.name || "?";
chip.append(dot, name);
const nr = document.createElement("div");
nr.className = "muted";
nr.textContent = `#${index + 1}`;
top.append(chip, nr);
const coords = document.createElement("div");
coords.className = "muted";
coords.textContent = `x=${Math.round(ann.x)}, y=${Math.round(ann.y)}, w=${Math.round(ann.width)}, h=${Math.round(ann.height)}`;
const row = document.createElement("div");
row.className = "row";
const selectBtn = document.createElement("button");
selectBtn.textContent = "Zaznacz";
selectBtn.addEventListener("click", () => {
state.selectedId = ann.id;
toolSelect.value = "select";
render();
});
const relabelBtn = document.createElement("button");
relabelBtn.textContent = "Ustaw aktywną";
relabelBtn.addEventListener("click", () => {
state.currentLabelId = ann.labelId;
render();
setStatus("Aktywna etykieta ustawiona z wybranej adnotacji.");
});
const removeBtn = document.createElement("button");
removeBtn.className = "danger";
removeBtn.textContent = "Usuń";
removeBtn.addEventListener("click", () => removeAnnotation(ann.id));
row.append(selectBtn, relabelBtn, removeBtn);
item.append(top, coords, row);
annotationList.appendChild(item);
});
}
function updateStats() {
imageCountBox.textContent = String(state.images.length);
countBox.textContent = String(currentAnnotations().length);
classCountBox.textContent = String(state.labels.length);
}
function removeAnnotation(id) {
const image = currentImage();
if (!image) return;
image.annotations = image.annotations.filter(ann => ann.id !== id);
if (state.selectedId === id) state.selectedId = null;
render();
}
function createAnnotationFromDraft() {
const image = currentImage();
if (!image || !state.draft || !state.currentLabelId) return;
if (state.draft.width < getMinBoxSize() || state.draft.height < getMinBoxSize()) {
state.draft = null;
state.drawing = false;
render();
return;
}
image.annotations.push({
id: state.draft.id,
labelId: state.currentLabelId,
x: state.draft.x,
y: state.draft.y,
width: state.draft.width,
height: state.draft.height
});
state.selectedId = state.draft.id;
state.draft = null;
state.drawing = false;
render();
}
function startInteraction(annotation, type, event, handle = null) {
const { x, y } = getMousePos(event);
state.interaction = {
type,
annotationId: annotation.id,
handle,
startX: x,
startY: y,
origin: { x: annotation.x, y: annotation.y, width: annotation.width, height: annotation.height },
offsetX: x - annotation.x,
offsetY: y - annotation.y
};
}
function resizeFromHandle(origin, handle, pointX, pointY) {
let x1 = origin.x;
let y1 = origin.y;
let x2 = origin.x + origin.width;
let y2 = origin.y + origin.height;
if (handle.includes("n")) y1 = pointY;
if (handle.includes("s")) y2 = pointY;
if (handle.includes("w")) x1 = pointX;
if (handle.includes("e")) x2 = pointX;
const rect = normalizeRect(x1, y1, x2, y2);
return clampRect(rect);
}
function handlePointerDown(event) {
const image = currentImage();
if (!image) return;
state.pointerDown = true;
const { x, y } = getMousePos(event);
if (toolSelect.value === "draw") {
if (!state.currentLabelId) {
setStatus("Najpierw dodaj lub wybierz etykietę.");
return;
}
state.drawing = true;
state.draft = { id: uid(), x1: x, y1: y, x2: x, y2: y, x, y, width: 0, height: 0, labelId: state.currentLabelId };
render();
return;
}
const selected = selectedAnnotation();
if (selected) {
const handle = hitHandle(selected, x, y);
if (handle) {
state.selectedId = selected.id;
startInteraction(selected, "resize", event, handle);
render();
return;
}
}
const hit = findTopAnnotation(x, y);
if (hit) {
state.selectedId = hit.id;
startInteraction(hit, "move", event);
render();
} else {
state.selectedId = null;
state.interaction = null;
render();
}
}
function handlePointerMove(event) {
if (!currentImage()) {
canvas.style.cursor = "default";
return;
}
const { x, y } = getMousePos(event);
if (state.drawing && state.draft) {
state.draft.x2 = x;
state.draft.y2 = y;
Object.assign(state.draft, normalizeRect(state.draft.x1, state.draft.y1, x, y));
render();
return;
}
if (state.interaction) {
const annotation = selectedAnnotation();
if (!annotation) {
state.interaction = null;
return;
}
if (state.interaction.type === "move") {
const nextX = clamp(x - state.interaction.offsetX, 0, canvas.width - annotation.width);
const nextY = clamp(y - state.interaction.offsetY, 0, canvas.height - annotation.height);
annotation.x = nextX;
annotation.y = nextY;
} else if (state.interaction.type === "resize") {
const rect = resizeFromHandle(state.interaction.origin, state.interaction.handle, clamp(x, 0, canvas.width), clamp(y, 0, canvas.height));
annotation.x = rect.x;
annotation.y = rect.y;
annotation.width = rect.width;
annotation.height = rect.height;
}
render();
return;
}
updateCursor(event);
}
function handlePointerUp(event) {
if (state.drawing && state.draft) {
createAnnotationFromDraft();
}
if (state.interaction) {
const annotation = selectedAnnotation();
if (annotation && (annotation.width < getMinBoxSize() / 2 || annotation.height < getMinBoxSize() / 2)) {
removeAnnotation(annotation.id);
}
state.interaction = null;
render();
}
state.pointerDown = false;
if (event) updateCursor(event);
}
function applyCurrentLabelToSelection() {
const annotation = selectedAnnotation();
if (!annotation) {
setStatus("Najpierw zaznacz adnotację.");
return;
}
if (!state.currentLabelId) {
setStatus("Najpierw wybierz etykietę.");
return;
}
annotation.labelId = state.currentLabelId;
render();
const label = getLabelById(state.currentLabelId);
setStatus(`Przypisano etykietę: ${label?.name || "?"}`);
}
function exportSettingsPayload() {
ensureSettings();
return {
schema: "annotator-settings",
version: 1,
exportedAt: new Date().toISOString(),
settings: { ...state.settings },
labels: state.labels.map(label => ({
id: label.id,
name: label.name,
color: label.color
})),
currentLabelId: state.currentLabelId,
tool: toolSelect.value || "draw"
};
}
function exportSettingsJson() {
const payload = exportSettingsPayload();
downloadText("annotator-settings.json", JSON.stringify(payload, null, 2));
setStatus("Wyeksportowano ustawienia do pliku JSON.");
}
function normalizeImportedLabels(labels) {
const normalized = [];
const seen = new Set();
for (const item of Array.isArray(labels) ? labels : []) {
const name = String(item?.name || "").trim();
if (!name) continue;
const lowered = name.toLowerCase();
if (seen.has(lowered)) continue;
seen.add(lowered);
normalized.push({
id: typeof item?.id === "string" && item.id ? item.id : uid(),
name,
color: typeof item?.color === "string" && item.color ? item.color : "#22c55e"
});
}
return normalized;
}
function importSettingsPayload(payload) {
if (!payload || typeof payload !== "object") {
throw new Error("Nieprawidłowy plik ustawień.");
}
if (payload.schema && payload.schema !== "annotator-settings") {
throw new Error("To nie wygląda na plik ustawień annotatora.");
}
const nextLabels = normalizeImportedLabels(payload.labels);
if (!nextLabels.length) {
throw new Error("Plik nie zawiera żadnej poprawnej etykiety.");
}
state.settings = sanitizeSettings(payload.settings || {});
state.labels = nextLabels;
const importedCurrentLabelId = typeof payload.currentLabelId === "string" ? payload.currentLabelId : null;
const hasImportedCurrent = importedCurrentLabelId && state.labels.some(label => label.id === importedCurrentLabelId);
if (hasImportedCurrent && !state.settings.autoSelectFirstLabelOnImport) {
state.currentLabelId = importedCurrentLabelId;
} else {
state.currentLabelId = state.labels[0].id;
}
if (payload.tool === "draw" || payload.tool === "select") {
toolSelect.value = payload.tool;
}
syncSettingsForm();
render();
setStatus(`Wczytano ustawienia: ${state.labels.length} etykiet.`);
}
async function importSettingsFromFile(file) {
if (!file) return;
const text = await file.text();
let payload;
try {
payload = JSON.parse(text);
} catch (error) {
throw new Error("Nie udało się odczytać JSON-a z pliku ustawień.");
}
importSettingsPayload(payload);
}
function buildClassList() {
return state.labels.map(label => label.name);
}
function getClassIndexMap() {
return new Map(state.labels.map((label, index) => [label.id, index]));
}
function yoloLine(annotation, imageWidth, imageHeight, classIndex) {
const xCenter = (annotation.x + annotation.width / 2) / imageWidth;
const yCenter = (annotation.y + annotation.height / 2) / imageHeight;
const width = annotation.width / imageWidth;
const height = annotation.height / imageHeight;
return [classIndex, round(xCenter), round(yCenter), round(width), round(height)].join(" ");
}
function baseName(filename) {
return filename.replace(/\.[^.]+$/, "");
}
function safeName(name) {
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
}
function currentYoloText() {
const image = currentImage();
if (!image) return "";
const classMap = getClassIndexMap();
return image.annotations
.filter(ann => classMap.has(ann.labelId))
.map(ann => yoloLine(ann, image.width, image.height, classMap.get(ann.labelId)))
.join("\n");
}
function downloadText(filename, text) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function yamlNames(labels) {
return "[" + labels.map(label => JSON.stringify(label)).join(", ") + "]";
}
async function exportCurrentYolo() {
const image = currentImage();
if (!image) {
setStatus("Najpierw wgraj obrazki.");
return;
}
downloadText(`${safeName(baseName(image.name))}.txt`, currentYoloText());
setStatus(`Wyeksportowano YOLO dla: ${image.name}`);
}
async function writeTextFile(dirHandle, filename, text) {
const fileHandle = await dirHandle.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(text);
await writable.close();
}
async function writeBinaryFile(dirHandle, filename, arrayBuffer) {
const fileHandle = await dirHandle.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(arrayBuffer);
await writable.close();
}
async function exportAllYolo() {
if (!state.images.length) {
setStatus("Najpierw wgraj obrazki.");
return;
}
const classes = buildClassList();
const classMap = getClassIndexMap();
const classesTxt = classes.join("\n");
const dataYaml = [
"path: .",
"train: images",
"val: images",
`nc: ${classes.length}`,
`names: ${yamlNames(classes)}`
].join("\n");
if (window.showDirectoryPicker) {
try {
const root = await window.showDirectoryPicker({ id: "yolo-export" });
const imagesDir = await root.getDirectoryHandle("images", { create: true });
const labelsDir = await root.getDirectoryHandle("labels", { create: true });
await writeTextFile(root, "classes.txt", classesTxt);
await writeTextFile(root, "data.yaml", dataYaml);
for (const image of state.images) {
const labelText = image.annotations
.filter(ann => classMap.has(ann.labelId))
.map(ann => yoloLine(ann, image.width, image.height, classMap.get(ann.labelId)))
.join("\n");
await writeTextFile(labelsDir, `${safeName(baseName(image.name))}.txt`, labelText);
await writeBinaryFile(imagesDir, image.name, await image.file.arrayBuffer());
}
setStatus("Wyeksportowano cały zestaw YOLO do wybranego katalogu.");
return;
} catch (error) {
console.error(error);
setStatus("Eksport katalogowy anulowany lub nieudany. Uruchamiam awaryjne pobieranie plików.");
}
}
downloadText("classes.txt", classesTxt);
downloadText("data.yaml", dataYaml);
for (const image of state.images) {
const labelText = image.annotations
.filter(ann => classMap.has(ann.labelId))
.map(ann => yoloLine(ann, image.width, image.height, classMap.get(ann.labelId)))
.join("\n");
downloadText(`${safeName(baseName(image.name))}.txt`, labelText);
}
setStatus("Wyeksportowano pliki YOLO jako osobne pobrania.");
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("./sw.js");
registration.update();
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
} catch (error) {
console.error("Błąd rejestracji service workera:", error);
}
});
}
exportSettingsBtn.addEventListener("click", exportSettingsJson);
importSettingsBtn.addEventListener("click", () => settingsFileInput.click());
settingsFileInput.addEventListener("change", async () => {
const file = settingsFileInput.files?.[0];
if (!file) return;
try {
await importSettingsFromFile(file);
} catch (error) {
console.error(error);
setStatus(error.message || "Nie udało się wczytać ustawień.");
} finally {
settingsFileInput.value = "";
}
});
[minBoxSizeInput, zoomStepInput, fitOnLoadInput, autoSelectLabelInput].forEach(element => {
element.addEventListener("change", applySettingsFromForm);
});
addLabelBtn.addEventListener("click", () => {
addLabel(newLabelInput.value, newLabelColor.value);
newLabelInput.value = "";
});
newLabelInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
addLabelBtn.click();
}
});
currentLabelSelect.addEventListener("change", () => {
state.currentLabelId = currentLabelSelect.value || null;
render();
});
imageInput.addEventListener("change", async (event) => {
await addImages(event.target.files);
imageInput.value = "";
});
applyLabelBtn.addEventListener("click", applyCurrentLabelToSelection);
prevBtn.addEventListener("click", () => state.images.length && switchImage(Math.max(0, state.currentImageIndex - 1), state.settings?.fitOnImageLoad ?? true));
nextBtn.addEventListener("click", () => state.images.length && switchImage(Math.min(state.images.length - 1, state.currentImageIndex + 1), state.settings?.fitOnImageLoad ?? true));
zoomRange.addEventListener("input", () => setZoom(Number(zoomRange.value) / 100));
zoomOutBtn.addEventListener("click", () => setZoom(state.zoom - getZoomStep()));
zoomInBtn.addEventListener("click", () => setZoom(state.zoom + getZoomStep()));
fitBtn.addEventListener("click", fitToViewport);
actualSizeBtn.addEventListener("click", () => setZoom(1));
undoBtn.addEventListener("click", () => {
const image = currentImage();
if (!image) return;
image.annotations.pop();
if (!image.annotations.find(ann => ann.id === state.selectedId)) state.selectedId = null;
render();
});
deleteBtn.addEventListener("click", () => {
if (state.selectedId) removeAnnotation(state.selectedId);
});
clearCurrentBtn.addEventListener("click", () => {
const image = currentImage();
if (!image) return;
if (!confirm(`Usunąć wszystkie adnotacje z obrazka ${image.name}?`)) return;
image.annotations = [];
state.selectedId = null;
render();
setStatus(`Wyczyszczono adnotacje: ${image.name}`);
});
clearAllBtn.addEventListener("click", () => {
if (!state.images.length) return;
if (!confirm("Usunąć wszystkie obrazki i wszystkie adnotacje?")) return;
for (const image of state.images) {
if (image.objectUrl) URL.revokeObjectURL(image.objectUrl);
}
state.images = [];
state.currentImageIndex = -1;
state.selectedId = null;
state.draft = null;
state.interaction = null;
canvas.width = 960;
canvas.height = 540;
render();
updateCanvasScale();
setStatus("Wyczyszczono cały zestaw.");
});
exportCurrentBtn.addEventListener("click", exportCurrentYolo);
exportAllBtn.addEventListener("click", exportAllYolo);
canvas.addEventListener("mousedown", handlePointerDown);
canvas.addEventListener("mousemove", handlePointerMove);
canvas.addEventListener("mouseup", handlePointerUp);
canvas.addEventListener("mouseleave", handlePointerUp);
canvas.addEventListener("wheel", (event) => {
if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault();
setZoom(state.zoom + (event.deltaY > 0 ? -getZoomStep() : getZoomStep()));
}, { passive: false });
document.addEventListener("keydown", (event) => {
const inTextField = event.target.matches("input[type='text'], textarea");
if (event.key === "Delete" && state.selectedId && !inTextField) {
removeAnnotation(state.selectedId);
}
if (event.key === "Escape") {
state.selectedId = null;
state.interaction = null;
state.draft = null;
state.drawing = false;
render();
}
if (!inTextField && event.key === "ArrowLeft") prevBtn.click();
if (!inTextField && event.key === "ArrowRight") nextBtn.click();
if ((event.ctrlKey || event.metaKey) && event.key === "0") {
event.preventDefault();
fitToViewport();
}
if ((event.ctrlKey || event.metaKey) && (event.key === "+" || event.key === "=")) {
event.preventDefault();
setZoom(state.zoom + getZoomStep());
}
if ((event.ctrlKey || event.metaKey) && event.key === "-") {
event.preventDefault();
setZoom(state.zoom - getZoomStep());
}
});
window.addEventListener("resize", updateCanvasScale);
ensureDefaultLabel();
syncSettingsForm();
setZoom(1);
render();
registerServiceWorker();
</script>
</body>
</html>