Spaces:
Running
Running
| <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> | |