studentscolab commited on
Commit
ea32c8b
·
verified ·
1 Parent(s): 9ea2e04

Upload 6 files

Browse files
Files changed (6) hide show
  1. icon-192.png +0 -0
  2. icon-512.png +0 -0
  3. icon.svg +8 -0
  4. index.html +1680 -17
  5. manifest.json +29 -0
  6. sw.js +55 -0
icon-192.png ADDED
icon-512.png ADDED
icon.svg ADDED
index.html CHANGED
@@ -1,19 +1,1682 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="pl">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Annotator YOLO PWA</title>
7
+ <meta name="theme-color" content="#111827" />
8
+ <link rel="manifest" href="manifest.json" />
9
+ <style>
10
+ :root {
11
+ --bg: #020617;
12
+ --panel: #111827;
13
+ --panel-2: #1f2937;
14
+ --panel-3: #0f172a;
15
+ --text: #e5e7eb;
16
+ --muted: #94a3b8;
17
+ --border: #334155;
18
+ --accent: #22c55e;
19
+ --danger: #ef4444;
20
+ --active: #2563eb;
21
+ --warning: #f59e0b;
22
+ }
23
+
24
+ * { box-sizing: border-box; }
25
+
26
+ body {
27
+ margin: 0;
28
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ }
32
+
33
+ .app {
34
+ display: grid;
35
+ grid-template-columns: 380px 1fr;
36
+ min-height: 100vh;
37
+ }
38
+
39
+ .sidebar {
40
+ background: var(--panel);
41
+ border-right: 1px solid var(--border);
42
+ padding: 16px;
43
+ overflow-y: auto;
44
+ }
45
+
46
+ .main {
47
+ display: flex;
48
+ flex-direction: column;
49
+ min-width: 0;
50
+ min-height: 100vh;
51
+ }
52
+
53
+ h1 {
54
+ margin: 0 0 8px;
55
+ font-size: 22px;
56
+ }
57
+
58
+ .hint {
59
+ color: var(--muted);
60
+ font-size: 14px;
61
+ line-height: 1.5;
62
+ margin-bottom: 16px;
63
+ }
64
+
65
+ .panel {
66
+ background: rgba(255,255,255,0.02);
67
+ border: 1px solid var(--border);
68
+ border-radius: 14px;
69
+ padding: 12px;
70
+ margin-bottom: 14px;
71
+ }
72
+
73
+ .panel h2 {
74
+ margin: 0 0 10px;
75
+ font-size: 15px;
76
+ }
77
+
78
+ .stats {
79
+ display: grid;
80
+ grid-template-columns: repeat(3, 1fr);
81
+ gap: 8px;
82
+ }
83
+
84
+ .stat {
85
+ background: var(--panel-2);
86
+ border: 1px solid var(--border);
87
+ border-radius: 10px;
88
+ padding: 10px;
89
+ }
90
+
91
+ .stat .label { font-size: 12px; color: var(--muted); }
92
+ .stat .value { font-size: 19px; font-weight: 700; margin-top: 2px; }
93
+
94
+ .toolbar {
95
+ display: flex;
96
+ flex-wrap: wrap;
97
+ gap: 10px;
98
+ align-items: end;
99
+ padding: 12px 16px;
100
+ background: var(--panel);
101
+ border-bottom: 1px solid var(--border);
102
+ }
103
+
104
+ .field {
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 6px;
108
+ }
109
+
110
+ .field label {
111
+ font-size: 12px;
112
+ color: var(--muted);
113
+ }
114
+
115
+ input[type="text"], input[type="file"], input[type="range"], input[type="color"], select, button {
116
+ font: inherit;
117
+ }
118
+
119
+ input[type="text"], select {
120
+ background: var(--panel-2);
121
+ color: var(--text);
122
+ border: 1px solid var(--border);
123
+ border-radius: 10px;
124
+ padding: 10px 12px;
125
+ min-width: 140px;
126
+ }
127
+
128
+ input[type="range"] { width: 170px; }
129
+
130
+ button {
131
+ background: var(--panel-2);
132
+ color: var(--text);
133
+ border: 1px solid var(--border);
134
+ border-radius: 10px;
135
+ padding: 10px 14px;
136
+ cursor: pointer;
137
+ }
138
+
139
+ button:hover { filter: brightness(1.08); }
140
+ button.primary { background: #14532d; border-color: #166534; }
141
+ button.secondary { background: #1e3a8a; border-color: #1d4ed8; }
142
+ button.danger { background: #7f1d1d; border-color: #991b1b; }
143
+ button.warning { background: #78350f; border-color: #b45309; }
144
+
145
+ .canvas-wrap {
146
+ flex: 1;
147
+ overflow: auto;
148
+ background:
149
+ linear-gradient(45deg, #0b1220 25%, transparent 25%),
150
+ linear-gradient(-45deg, #0b1220 25%, transparent 25%),
151
+ linear-gradient(45deg, transparent 75%, #0b1220 75%),
152
+ linear-gradient(-45deg, transparent 75%, #0b1220 75%);
153
+ background-size: 24px 24px;
154
+ background-position: 0 0, 0 12px, 12px -12px, -12px 0;
155
+ padding: 16px;
156
+ min-height: 0;
157
+ display: flex;
158
+ align-items: flex-start;
159
+ justify-content: flex-start;
160
+ }
161
+
162
+ .canvas-stage {
163
+ display: inline-block;
164
+ line-height: 0;
165
+ }
166
+
167
+ canvas {
168
+ display: block;
169
+ background: #000;
170
+ border-radius: 12px;
171
+ box-shadow: 0 10px 30px rgba(0,0,0,0.35);
172
+ transform-origin: top left;
173
+ max-width: none;
174
+ cursor: crosshair;
175
+ }
176
+
177
+ .list {
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: 8px;
181
+ max-height: 280px;
182
+ overflow: auto;
183
+ }
184
+
185
+ .item {
186
+ background: var(--panel-2);
187
+ border: 1px solid var(--border);
188
+ border-radius: 12px;
189
+ padding: 10px;
190
+ display: grid;
191
+ gap: 8px;
192
+ }
193
+
194
+ .item.selected { outline: 2px solid var(--accent); }
195
+ .item.active { outline: 2px solid var(--active); }
196
+
197
+ .item-top {
198
+ display: flex;
199
+ justify-content: space-between;
200
+ gap: 8px;
201
+ align-items: center;
202
+ }
203
+
204
+ .row {
205
+ display: flex;
206
+ gap: 8px;
207
+ flex-wrap: wrap;
208
+ align-items: center;
209
+ }
210
+
211
+ .muted { color: var(--muted); font-size: 12px; }
212
+
213
+ .label-chip {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ border-radius: 999px;
218
+ padding: 4px 10px;
219
+ border: 1px solid var(--border);
220
+ font-size: 12px;
221
+ width: fit-content;
222
+ background: rgba(255,255,255,0.04);
223
+ }
224
+
225
+ .color-dot {
226
+ width: 12px;
227
+ height: 12px;
228
+ border-radius: 999px;
229
+ display: inline-block;
230
+ border: 1px solid rgba(255,255,255,0.2);
231
+ }
232
+
233
+ .inline-input {
234
+ min-width: 0;
235
+ width: 100%;
236
+ background: var(--panel-3);
237
+ border: 1px solid var(--border);
238
+ color: var(--text);
239
+ border-radius: 8px;
240
+ padding: 8px 10px;
241
+ }
242
+
243
+ .inline-color {
244
+ width: 42px;
245
+ height: 38px;
246
+ border-radius: 8px;
247
+ border: 1px solid var(--border);
248
+ background: transparent;
249
+ padding: 2px;
250
+ }
251
+
252
+ .inline-number {
253
+ width: 100%;
254
+ background: var(--panel-3);
255
+ border: 1px solid var(--border);
256
+ color: var(--text);
257
+ border-radius: 8px;
258
+ padding: 8px 10px;
259
+ }
260
+
261
+ .check-row {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 8px;
265
+ color: var(--text);
266
+ font-size: 13px;
267
+ padding: 4px 0;
268
+ }
269
+
270
+ .check-row input[type="checkbox"] {
271
+ width: 16px;
272
+ height: 16px;
273
+ }
274
+
275
+ .footer-note {
276
+ font-size: 12px;
277
+ color: var(--muted);
278
+ line-height: 1.5;
279
+ margin-top: 10px;
280
+ }
281
+
282
+ .zoom-value {
283
+ min-width: 54px;
284
+ text-align: center;
285
+ padding: 10px 0;
286
+ font-weight: 600;
287
+ }
288
+
289
+ .status {
290
+ min-height: 28px;
291
+ border-top: 1px solid var(--border);
292
+ background: #0b1220;
293
+ color: var(--muted);
294
+ padding: 8px 16px;
295
+ font-size: 13px;
296
+ }
297
+
298
+ code {
299
+ background: rgba(255,255,255,0.08);
300
+ padding: 2px 6px;
301
+ border-radius: 6px;
302
+ }
303
+
304
+ @media (max-width: 1100px) {
305
+ .app { grid-template-columns: 1fr; }
306
+ .sidebar {
307
+ border-right: none;
308
+ border-bottom: 1px solid var(--border);
309
+ }
310
+ }
311
+ </style>
312
+ </head>
313
+ <body>
314
+ <div class="app">
315
+ <aside class="sidebar">
316
+ <h1>Annotator YOLO</h1>
317
+ <div class="hint">
318
+ Zdefiniuj etykiety, wybierz aktywną klasę, oznaczaj obiekty i poprawiaj boxy przez przeciąganie.
319
+ W trybie <strong>Zaznaczanie</strong> możesz przesuwać box lub łapać za uchwyty, żeby zmienić jego rozmiar.
320
+ </div>
321
+
322
+ <div class="panel">
323
+ <h2>Podsumowanie</h2>
324
+ <div class="stats">
325
+ <div class="stat"><div class="label">Obrazki</div><div class="value" id="imageCountBox">0</div></div>
326
+ <div class="stat"><div class="label">Adnotacje</div><div class="value" id="countBox">0</div></div>
327
+ <div class="stat"><div class="label">Klasy</div><div class="value" id="classCountBox">0</div></div>
328
+ </div>
329
+ </div>
330
+
331
+ <div class="panel">
332
+ <h2>Etykiety</h2>
333
+ <div class="row" style="margin-bottom:10px;">
334
+ <input id="newLabelInput" class="inline-input" type="text" placeholder="Nowa etykieta, np. samochód" />
335
+ <input id="newLabelColor" class="inline-color" type="color" value="#22c55e" />
336
+ <button id="addLabelBtn">Dodaj</button>
337
+ </div>
338
+ <div id="labelList" class="list"></div>
339
+ </div>
340
+
341
+ <div class="panel">
342
+ <h2>Ustawienia</h2>
343
+ <div class="row" style="margin-bottom:10px;">
344
+ <button id="exportSettingsBtn" class="secondary">Eksport ustawień JSON</button>
345
+ <button id="importSettingsBtn">Import ustawień</button>
346
+ <input id="settingsFileInput" type="file" accept=".json,application/json" hidden />
347
+ </div>
348
+ <div class="row" style="margin-bottom:10px;">
349
+ <div style="flex:1; min-width:140px;">
350
+ <div class="muted" style="margin-bottom:4px;">Minimalny box (px)</div>
351
+ <input id="minBoxSizeInput" class="inline-number" type="number" min="1" max="200" step="1" value="8" />
352
+ </div>
353
+ <div style="flex:1; min-width:140px;">
354
+ <div class="muted" style="margin-bottom:4px;">Krok zoomu (%)</div>
355
+ <input id="zoomStepInput" class="inline-number" type="number" min="1" max="100" step="1" value="10" />
356
+ </div>
357
+ </div>
358
+ <div class="row" style="margin-bottom:6px;">
359
+ <label class="check-row"><input id="fitOnLoadInput" type="checkbox" checked /> Dopasuj obraz po wczytaniu</label>
360
+ </div>
361
+ <div class="row" style="margin-bottom:6px;">
362
+ <label class="check-row"><input id="autoSelectLabelInput" type="checkbox" checked /> Po imporcie ustaw aktywną pierwszą etykietę</label>
363
+ </div>
364
+ <div class="muted">Te ustawienia oraz lista etykiet mogą być zapisane do pliku JSON i wczytane ponownie.</div>
365
+ </div>
366
+
367
+ <div class="panel">
368
+ <h2>Lista obrazków</h2>
369
+ <div id="imageList" class="list"></div>
370
+ </div>
371
+
372
+ <div class="panel">
373
+ <h2>Adnotacje bieżącego obrazka</h2>
374
+ <div id="annotationList" class="list"></div>
375
+ </div>
376
+
377
+ <div class="footer-note">
378
+ Eksport YOLO tworzy plik <code>classes.txt</code>, plik <code>data.yaml</code> oraz pliki etykiet <code>.txt</code>
379
+ z wierszami <code>class_id x_center y_center width height</code>. Kolejność klas pochodzi z listy etykiet po lewej.
380
+ </div>
381
+ </aside>
382
+
383
+ <main class="main">
384
+ <div class="toolbar">
385
+ <div class="field">
386
+ <label for="imageInput">Obrazki</label>
387
+ <input id="imageInput" type="file" accept="image/*" multiple />
388
+ </div>
389
+
390
+ <div class="field">
391
+ <label for="toolSelect">Tryb</label>
392
+ <select id="toolSelect">
393
+ <option value="draw">Rysowanie</option>
394
+ <option value="select">Zaznaczanie</option>
395
+ </select>
396
+ </div>
397
+
398
+ <div class="field">
399
+ <label for="currentLabelSelect">Aktywna etykieta</label>
400
+ <select id="currentLabelSelect"></select>
401
+ </div>
402
+
403
+ <button id="applyLabelBtn">Przypisz etykietę zaznaczonej</button>
404
+ <button id="prevBtn">◀ Poprzedni</button>
405
+ <button id="nextBtn">Następny ▶</button>
406
+
407
+ <div class="field">
408
+ <label for="zoomRange">Zoom</label>
409
+ <input id="zoomRange" type="range" min="10" max="400" step="10" value="100" />
410
+ </div>
411
+
412
+ <div class="zoom-value" id="zoomValue">100%</div>
413
+ <button id="zoomOutBtn">-</button>
414
+ <button id="zoomInBtn">+</button>
415
+ <button id="fitBtn">Dopasuj</button>
416
+ <button id="actualSizeBtn">1:1</button>
417
+ <button id="undoBtn">Cofnij</button>
418
+ <button id="deleteBtn" class="danger">Usuń zaznaczoną</button>
419
+ <button id="clearCurrentBtn" class="warning">Wyczyść bieżący</button>
420
+ <button id="clearAllBtn" class="danger">Wyczyść wszystko</button>
421
+ <button id="exportCurrentBtn" class="secondary">Eksport YOLO: bieżący</button>
422
+ <button id="exportAllBtn" class="primary">Eksport YOLO: cały zestaw</button>
423
+ </div>
424
+
425
+ <div class="canvas-wrap" id="canvasWrap">
426
+ <div class="canvas-stage" id="canvasStage">
427
+ <canvas id="canvas" width="960" height="540"></canvas>
428
+ </div>
429
+ </div>
430
+
431
+ <div class="status" id="statusBar">Gotowe.</div>
432
+ </main>
433
+ </div>
434
+
435
+ <script>
436
+ const HANDLE_ORDER = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
437
+ 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" };
438
+
439
+ const canvas = document.getElementById("canvas");
440
+ const ctx = canvas.getContext("2d");
441
+ const canvasWrap = document.getElementById("canvasWrap");
442
+ const canvasStage = document.getElementById("canvasStage");
443
+ const imageInput = document.getElementById("imageInput");
444
+ const toolSelect = document.getElementById("toolSelect");
445
+ const currentLabelSelect = document.getElementById("currentLabelSelect");
446
+ const applyLabelBtn = document.getElementById("applyLabelBtn");
447
+ const prevBtn = document.getElementById("prevBtn");
448
+ const nextBtn = document.getElementById("nextBtn");
449
+ const zoomRange = document.getElementById("zoomRange");
450
+ const zoomValue = document.getElementById("zoomValue");
451
+ const zoomOutBtn = document.getElementById("zoomOutBtn");
452
+ const zoomInBtn = document.getElementById("zoomInBtn");
453
+ const fitBtn = document.getElementById("fitBtn");
454
+ const actualSizeBtn = document.getElementById("actualSizeBtn");
455
+ const undoBtn = document.getElementById("undoBtn");
456
+ const deleteBtn = document.getElementById("deleteBtn");
457
+ const clearCurrentBtn = document.getElementById("clearCurrentBtn");
458
+ const clearAllBtn = document.getElementById("clearAllBtn");
459
+ const exportCurrentBtn = document.getElementById("exportCurrentBtn");
460
+ const exportAllBtn = document.getElementById("exportAllBtn");
461
+ const imageList = document.getElementById("imageList");
462
+ const annotationList = document.getElementById("annotationList");
463
+ const labelList = document.getElementById("labelList");
464
+ const newLabelInput = document.getElementById("newLabelInput");
465
+ const newLabelColor = document.getElementById("newLabelColor");
466
+ const addLabelBtn = document.getElementById("addLabelBtn");
467
+ const exportSettingsBtn = document.getElementById("exportSettingsBtn");
468
+ const importSettingsBtn = document.getElementById("importSettingsBtn");
469
+ const settingsFileInput = document.getElementById("settingsFileInput");
470
+ const minBoxSizeInput = document.getElementById("minBoxSizeInput");
471
+ const zoomStepInput = document.getElementById("zoomStepInput");
472
+ const fitOnLoadInput = document.getElementById("fitOnLoadInput");
473
+ const autoSelectLabelInput = document.getElementById("autoSelectLabelInput");
474
+ const imageCountBox = document.getElementById("imageCountBox");
475
+ const countBox = document.getElementById("countBox");
476
+ const classCountBox = document.getElementById("classCountBox");
477
+ const statusBar = document.getElementById("statusBar");
478
+
479
+ const state = {
480
+ images: [],
481
+ currentImageIndex: -1,
482
+ labels: [],
483
+ currentLabelId: null,
484
+ selectedId: null,
485
+ drawing: false,
486
+ draft: null,
487
+ zoom: 1,
488
+ interaction: null,
489
+ pointerDown: false,
490
+ settings: null
491
+ };
492
+
493
+ function uid() {
494
+ return Math.random().toString(36).slice(2, 10);
495
+ }
496
+
497
+ function clamp(value, min, max) {
498
+ return Math.min(max, Math.max(min, value));
499
+ }
500
+
501
+ function round(value, digits = 6) {
502
+ return Number(value.toFixed(digits));
503
+ }
504
+
505
+ function defaultSettings() {
506
+ return {
507
+ schemaVersion: 1,
508
+ minBoxSize: 8,
509
+ zoomStep: 0.1,
510
+ fitOnImageLoad: true,
511
+ autoSelectFirstLabelOnImport: true
512
+ };
513
+ }
514
+
515
+ function sanitizeSettings(input = {}) {
516
+ const defaults = defaultSettings();
517
+ const minBoxSize = Number(input.minBoxSize);
518
+ const zoomStep = Number(input.zoomStep);
519
+ return {
520
+ schemaVersion: 1,
521
+ minBoxSize: Number.isFinite(minBoxSize) ? clamp(Math.round(minBoxSize), 1, 200) : defaults.minBoxSize,
522
+ zoomStep: Number.isFinite(zoomStep) ? clamp(zoomStep, 0.01, 1) : defaults.zoomStep,
523
+ fitOnImageLoad: typeof input.fitOnImageLoad === "boolean" ? input.fitOnImageLoad : defaults.fitOnImageLoad,
524
+ autoSelectFirstLabelOnImport: typeof input.autoSelectFirstLabelOnImport === "boolean" ? input.autoSelectFirstLabelOnImport : defaults.autoSelectFirstLabelOnImport
525
+ };
526
+ }
527
+
528
+ function ensureSettings() {
529
+ if (!state.settings) state.settings = defaultSettings();
530
+ state.settings = sanitizeSettings(state.settings);
531
+ }
532
+
533
+ function syncSettingsForm() {
534
+ ensureSettings();
535
+ minBoxSizeInput.value = String(state.settings.minBoxSize);
536
+ zoomStepInput.value = String(Math.round(state.settings.zoomStep * 100));
537
+ fitOnLoadInput.checked = !!state.settings.fitOnImageLoad;
538
+ autoSelectLabelInput.checked = !!state.settings.autoSelectFirstLabelOnImport;
539
+ }
540
+
541
+ function applySettingsFromForm() {
542
+ ensureSettings();
543
+ state.settings = sanitizeSettings({
544
+ minBoxSize: minBoxSizeInput.value,
545
+ zoomStep: Number(zoomStepInput.value) / 100,
546
+ fitOnImageLoad: fitOnLoadInput.checked,
547
+ autoSelectFirstLabelOnImport: autoSelectLabelInput.checked
548
+ });
549
+ syncSettingsForm();
550
+ setStatus("Zaktualizowano ustawienia aplikacji.");
551
+ }
552
+
553
+ function getMinBoxSize() {
554
+ ensureSettings();
555
+ return state.settings.minBoxSize;
556
+ }
557
+
558
+ function getZoomStep() {
559
+ ensureSettings();
560
+ return state.settings.zoomStep;
561
+ }
562
+
563
+ function setStatus(message) {
564
+ statusBar.textContent = message;
565
+ }
566
+
567
+ function currentImage() {
568
+ return state.images[state.currentImageIndex] || null;
569
+ }
570
+
571
+ function currentAnnotations() {
572
+ return currentImage()?.annotations || [];
573
+ }
574
+
575
+ function getLabelById(id) {
576
+ return state.labels.find(label => label.id === id) || null;
577
+ }
578
+
579
+ function selectedAnnotation() {
580
+ return currentAnnotations().find(ann => ann.id === state.selectedId) || null;
581
+ }
582
+
583
+ function ensureDefaultLabel() {
584
+ ensureSettings();
585
+ if (!state.labels.length) {
586
+ const label = { id: uid(), name: "obiekt", color: "#22c55e" };
587
+ state.labels.push(label);
588
+ state.currentLabelId = label.id;
589
+ }
590
+ }
591
+
592
+ function addLabel(name, color = "#22c55e") {
593
+ const cleaned = (name || "").trim();
594
+ if (!cleaned) {
595
+ setStatus("Podaj nazwę etykiety.");
596
+ return;
597
+ }
598
+ const duplicate = state.labels.find(label => label.name.toLowerCase() === cleaned.toLowerCase());
599
+ if (duplicate) {
600
+ state.currentLabelId = duplicate.id;
601
+ render();
602
+ setStatus(`Etykieta "${cleaned}" już istnieje.`);
603
+ return;
604
+ }
605
+ const label = { id: uid(), name: cleaned, color };
606
+ state.labels.push(label);
607
+ state.currentLabelId = label.id;
608
+ render();
609
+ setStatus(`Dodano etykietę: ${cleaned}`);
610
+ }
611
+
612
+ function renameLabel(labelId, nextName) {
613
+ const label = getLabelById(labelId);
614
+ if (!label) return;
615
+ const cleaned = (nextName || "").trim();
616
+ if (!cleaned) {
617
+ setStatus("Nazwa etykiety nie może być pusta.");
618
+ render();
619
+ return;
620
+ }
621
+ const duplicate = state.labels.find(item => item.id !== labelId && item.name.toLowerCase() === cleaned.toLowerCase());
622
+ if (duplicate) {
623
+ setStatus(`Etykieta "${cleaned}" już istnieje.`);
624
+ render();
625
+ return;
626
+ }
627
+ label.name = cleaned;
628
+ render();
629
+ setStatus(`Zmieniono nazwę etykiety na: ${cleaned}`);
630
+ }
631
+
632
+ function updateLabelColor(labelId, color) {
633
+ const label = getLabelById(labelId);
634
+ if (!label) return;
635
+ label.color = color;
636
+ render();
637
+ setStatus(`Zmieniono kolor etykiety: ${label.name}`);
638
+ }
639
+
640
+ function countLabelUsage(labelId) {
641
+ let count = 0;
642
+ for (const image of state.images) {
643
+ for (const ann of image.annotations) {
644
+ if (ann.labelId === labelId) count += 1;
645
+ }
646
+ }
647
+ return count;
648
+ }
649
+
650
+ function removeLabel(labelId) {
651
+ if (state.labels.length <= 1) {
652
+ setStatus("Musi pozostać co najmniej jedna etykieta.");
653
+ return;
654
+ }
655
+ const usage = countLabelUsage(labelId);
656
+ const label = getLabelById(labelId);
657
+ if (usage > 0) {
658
+ setStatus(`Nie można usunąć etykiety "${label?.name}" — jest używana w ${usage} adnotacjach.`);
659
+ return;
660
+ }
661
+ state.labels = state.labels.filter(label => label.id !== labelId);
662
+ if (state.currentLabelId === labelId) {
663
+ state.currentLabelId = state.labels[0]?.id || null;
664
+ }
665
+ render();
666
+ setStatus("Usunięto etykietę.");
667
+ }
668
+
669
+ function setZoom(nextZoom) {
670
+ state.zoom = clamp(nextZoom, 0.1, 4);
671
+ zoomRange.value = String(Math.round(state.zoom * 100));
672
+ zoomValue.textContent = `${Math.round(state.zoom * 100)}%`;
673
+ updateCanvasScale();
674
+ }
675
+
676
+ function updateCanvasScale() {
677
+ const scaledWidth = canvas.width * state.zoom;
678
+ const scaledHeight = canvas.height * state.zoom;
679
+ canvas.style.width = `${scaledWidth}px`;
680
+ canvas.style.height = `${scaledHeight}px`;
681
+ canvasStage.style.width = `${scaledWidth}px`;
682
+ canvasStage.style.height = `${scaledHeight}px`;
683
+ }
684
+
685
+ function fitToViewport() {
686
+ const image = currentImage();
687
+ if (!image) {
688
+ setZoom(1);
689
+ return;
690
+ }
691
+ const availableWidth = Math.max(100, canvasWrap.clientWidth - 32);
692
+ const availableHeight = Math.max(100, canvasWrap.clientHeight - 32);
693
+ const zoomX = availableWidth / canvas.width;
694
+ const zoomY = availableHeight / canvas.height;
695
+ setZoom(Math.min(zoomX, zoomY, 1));
696
+ }
697
+
698
+ function getMousePos(event) {
699
+ const rect = canvas.getBoundingClientRect();
700
+ return {
701
+ x: (event.clientX - rect.left) * (canvas.width / rect.width),
702
+ y: (event.clientY - rect.top) * (canvas.height / rect.height)
703
+ };
704
+ }
705
+
706
+ function normalizeRect(x1, y1, x2, y2) {
707
+ return {
708
+ x: Math.min(x1, x2),
709
+ y: Math.min(y1, y2),
710
+ width: Math.abs(x2 - x1),
711
+ height: Math.abs(y2 - y1)
712
+ };
713
+ }
714
+
715
+ function clampRect(rect) {
716
+ const x = clamp(rect.x, 0, canvas.width);
717
+ const y = clamp(rect.y, 0, canvas.height);
718
+ const width = clamp(rect.width, 1, canvas.width - x);
719
+ const height = clamp(rect.height, 1, canvas.height - y);
720
+ return { x, y, width, height };
721
+ }
722
+
723
+ function pointInRect(x, y, rect) {
724
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
725
+ }
726
+
727
+ function getHandleRadius() {
728
+ return Math.max(6, 8 / state.zoom);
729
+ }
730
+
731
+ function getHandlePositions(rect) {
732
+ const x1 = rect.x;
733
+ const y1 = rect.y;
734
+ const x2 = rect.x + rect.width;
735
+ const y2 = rect.y + rect.height;
736
+ const cx = rect.x + rect.width / 2;
737
+ const cy = rect.y + rect.height / 2;
738
+ return {
739
+ nw: { x: x1, y: y1 },
740
+ n: { x: cx, y: y1 },
741
+ ne: { x: x2, y: y1 },
742
+ e: { x: x2, y: cy },
743
+ se: { x: x2, y: y2 },
744
+ s: { x: cx, y: y2 },
745
+ sw: { x: x1, y: y2 },
746
+ w: { x: x1, y: cy }
747
+ };
748
+ }
749
+
750
+ function hitHandle(annotation, x, y) {
751
+ const radius = getHandleRadius();
752
+ const positions = getHandlePositions(annotation);
753
+ for (const key of HANDLE_ORDER) {
754
+ const point = positions[key];
755
+ if (Math.abs(point.x - x) <= radius && Math.abs(point.y - y) <= radius) {
756
+ return key;
757
+ }
758
+ }
759
+ return null;
760
+ }
761
+
762
+ function findTopAnnotation(x, y) {
763
+ const annotations = [...currentAnnotations()].reverse();
764
+ return annotations.find(ann => pointInRect(x, y, ann)) || null;
765
+ }
766
+
767
+ function updateCursor(event) {
768
+ if (toolSelect.value === "draw") {
769
+ canvas.style.cursor = "crosshair";
770
+ return;
771
+ }
772
+ if (state.interaction) {
773
+ if (state.interaction.type === "resize") {
774
+ canvas.style.cursor = HANDLE_CURSOR[state.interaction.handle] || "move";
775
+ } else {
776
+ canvas.style.cursor = "move";
777
+ }
778
+ return;
779
+ }
780
+ const image = currentImage();
781
+ if (!image) {
782
+ canvas.style.cursor = "default";
783
+ return;
784
+ }
785
+ const { x, y } = getMousePos(event);
786
+ const selected = selectedAnnotation();
787
+ if (selected) {
788
+ const handle = hitHandle(selected, x, y);
789
+ if (handle) {
790
+ canvas.style.cursor = HANDLE_CURSOR[handle] || "pointer";
791
+ return;
792
+ }
793
+ }
794
+ const ann = findTopAnnotation(x, y);
795
+ canvas.style.cursor = ann ? "move" : "default";
796
+ }
797
+
798
+ async function loadImageFromFile(file) {
799
+ const objectUrl = URL.createObjectURL(file);
800
+ const img = new Image();
801
+ await new Promise((resolve, reject) => {
802
+ img.onload = resolve;
803
+ img.onerror = reject;
804
+ img.src = objectUrl;
805
+ });
806
+ return {
807
+ id: uid(),
808
+ name: file.name,
809
+ file,
810
+ objectUrl,
811
+ img,
812
+ width: img.naturalWidth,
813
+ height: img.naturalHeight,
814
+ annotations: []
815
+ };
816
+ }
817
+
818
+ async function addImages(files) {
819
+ const fileArray = Array.from(files || []).filter(file => file.type.startsWith("image/"));
820
+ if (!fileArray.length) return;
821
+ setStatus(`Wczytywanie ${fileArray.length} obrazków...`);
822
+ const loaded = [];
823
+ for (const file of fileArray) {
824
+ try {
825
+ loaded.push(await loadImageFromFile(file));
826
+ } catch (error) {
827
+ console.error("Nie udało się wczytać pliku:", file.name, error);
828
+ }
829
+ }
830
+ state.images.push(...loaded);
831
+ if (state.currentImageIndex === -1 && state.images.length) {
832
+ switchImage(0, state.settings?.fitOnImageLoad ?? true);
833
+ } else {
834
+ render();
835
+ }
836
+ setStatus(`Dodano ${loaded.length} obrazków.`);
837
+ }
838
+
839
+ function switchImage(index, autoFit = false) {
840
+ if (index < 0 || index >= state.images.length) return;
841
+ state.currentImageIndex = index;
842
+ state.selectedId = null;
843
+ state.draft = null;
844
+ state.interaction = null;
845
+ const image = currentImage();
846
+ canvas.width = image.width;
847
+ canvas.height = image.height;
848
+ render();
849
+ if (autoFit) {
850
+ requestAnimationFrame(fitToViewport);
851
+ } else {
852
+ updateCanvasScale();
853
+ }
854
+ setStatus(`Wybrano obrazek: ${image.name}`);
855
+ }
856
+
857
+ function removeImage(index) {
858
+ if (index < 0 || index >= state.images.length) return;
859
+ const [removed] = state.images.splice(index, 1);
860
+ if (removed?.objectUrl) URL.revokeObjectURL(removed.objectUrl);
861
+ if (!state.images.length) {
862
+ state.currentImageIndex = -1;
863
+ state.selectedId = null;
864
+ state.draft = null;
865
+ state.interaction = null;
866
+ canvas.width = 960;
867
+ canvas.height = 540;
868
+ render();
869
+ updateCanvasScale();
870
+ setStatus("Usunięto wszystkie obrazki.");
871
+ return;
872
+ }
873
+ switchImage(Math.min(index, state.images.length - 1), state.settings?.fitOnImageLoad ?? true);
874
+ }
875
+
876
+ function drawEmptyState() {
877
+ ctx.fillStyle = "#0f172a";
878
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
879
+ ctx.fillStyle = "#94a3b8";
880
+ ctx.textAlign = "center";
881
+ ctx.font = "24px system-ui";
882
+ ctx.fillText("Wgraj obrazki, aby rozpocząć", canvas.width / 2, canvas.height / 2 - 10);
883
+ ctx.font = "16px system-ui";
884
+ ctx.fillText("Zdefiniuj etykiety i rysuj boxy na wybranym obrazku", canvas.width / 2, canvas.height / 2 + 24);
885
+ }
886
+
887
+ function render() {
888
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
889
+ const image = currentImage();
890
+ if (image) {
891
+ ctx.drawImage(image.img, 0, 0, canvas.width, canvas.height);
892
+ } else {
893
+ drawEmptyState();
894
+ }
895
+ for (const ann of currentAnnotations()) {
896
+ drawAnnotation(ann, ann.id === state.selectedId);
897
+ }
898
+ if (state.draft) {
899
+ drawAnnotation(state.draft, false, true);
900
+ }
901
+ renderLabelList();
902
+ renderCurrentLabelSelect();
903
+ renderImageList();
904
+ renderAnnotationList();
905
+ updateStats();
906
+ }
907
+
908
+ function drawAnnotation(annotation, selected = false, draft = false) {
909
+ const label = getLabelById(annotation.labelId) || { name: annotation.labelName || "?", color: annotation.color || "#22c55e" };
910
+ const { x, y, width, height } = annotation;
911
+ ctx.save();
912
+ ctx.lineWidth = selected ? 3 : 2;
913
+ ctx.strokeStyle = label.color;
914
+ ctx.fillStyle = label.color;
915
+ if (draft) ctx.setLineDash([8, 6]);
916
+ ctx.strokeRect(x, y, width, height);
917
+
918
+ const text = label.name || "obiekt";
919
+ ctx.font = `${Math.max(12, 14 / Math.min(state.zoom, 1.5))}px system-ui`;
920
+ const textWidth = ctx.measureText(text).width;
921
+ const tagWidth = textWidth + 18;
922
+ const tagHeight = 24;
923
+ const tagX = x;
924
+ const tagY = Math.max(0, y - tagHeight);
925
+ ctx.fillRect(tagX, tagY, tagWidth, tagHeight);
926
+ ctx.fillStyle = "#08110a";
927
+ ctx.fillText(text, tagX + 9, tagY + 16);
928
+
929
+ if (selected) {
930
+ ctx.setLineDash([4, 4]);
931
+ ctx.strokeStyle = "#ffffff";
932
+ ctx.strokeRect(x - 2, y - 2, width + 4, height + 4);
933
+ drawHandles(annotation, label.color);
934
+ }
935
+ ctx.restore();
936
+ }
937
+
938
+ function drawHandles(annotation, color) {
939
+ const radius = getHandleRadius();
940
+ const positions = getHandlePositions(annotation);
941
+ ctx.save();
942
+ for (const key of HANDLE_ORDER) {
943
+ const point = positions[key];
944
+ ctx.fillStyle = "#ffffff";
945
+ ctx.strokeStyle = color;
946
+ ctx.lineWidth = 2;
947
+ ctx.beginPath();
948
+ ctx.rect(point.x - radius, point.y - radius, radius * 2, radius * 2);
949
+ ctx.fill();
950
+ ctx.stroke();
951
+ }
952
+ ctx.restore();
953
+ }
954
+
955
+ function renderLabelList() {
956
+ labelList.innerHTML = "";
957
+ if (!state.labels.length) {
958
+ const empty = document.createElement("div");
959
+ empty.className = "muted";
960
+ empty.textContent = "Brak etykiet.";
961
+ labelList.appendChild(empty);
962
+ return;
963
+ }
964
+ for (const label of state.labels) {
965
+ const item = document.createElement("div");
966
+ item.className = "item" + (label.id === state.currentLabelId ? " active" : "");
967
+
968
+ const top = document.createElement("div");
969
+ top.className = "item-top";
970
+
971
+ const chip = document.createElement("div");
972
+ chip.className = "label-chip";
973
+ const dot = document.createElement("span");
974
+ dot.className = "color-dot";
975
+ dot.style.background = label.color;
976
+ const name = document.createElement("span");
977
+ name.textContent = label.name;
978
+ chip.append(dot, name);
979
+
980
+ const usage = document.createElement("div");
981
+ usage.className = "muted";
982
+ usage.textContent = `${countLabelUsage(label.id)} użyć`;
983
+
984
+ top.append(chip, usage);
985
+
986
+ const row1 = document.createElement("div");
987
+ row1.className = "row";
988
+ const nameInput = document.createElement("input");
989
+ nameInput.type = "text";
990
+ nameInput.value = label.name;
991
+ nameInput.className = "inline-input";
992
+ nameInput.addEventListener("change", () => renameLabel(label.id, nameInput.value));
993
+ const colorInput = document.createElement("input");
994
+ colorInput.type = "color";
995
+ colorInput.value = label.color;
996
+ colorInput.className = "inline-color";
997
+ colorInput.addEventListener("input", () => updateLabelColor(label.id, colorInput.value));
998
+ row1.append(nameInput, colorInput);
999
+
1000
+ const row2 = document.createElement("div");
1001
+ row2.className = "row";
1002
+ const selectBtn = document.createElement("button");
1003
+ selectBtn.textContent = label.id === state.currentLabelId ? "Aktywna" : "Wybierz";
1004
+ selectBtn.addEventListener("click", () => {
1005
+ state.currentLabelId = label.id;
1006
+ render();
1007
+ setStatus(`Aktywna etykieta: ${label.name}`);
1008
+ });
1009
+ const deleteBtn = document.createElement("button");
1010
+ deleteBtn.className = "danger";
1011
+ deleteBtn.textContent = "Usuń";
1012
+ deleteBtn.addEventListener("click", () => removeLabel(label.id));
1013
+ row2.append(selectBtn, deleteBtn);
1014
+
1015
+ item.append(top, row1, row2);
1016
+ labelList.appendChild(item);
1017
+ }
1018
+ }
1019
+
1020
+ function renderCurrentLabelSelect() {
1021
+ currentLabelSelect.innerHTML = "";
1022
+ for (const label of state.labels) {
1023
+ const option = document.createElement("option");
1024
+ option.value = label.id;
1025
+ option.textContent = label.name;
1026
+ if (label.id === state.currentLabelId) option.selected = true;
1027
+ currentLabelSelect.appendChild(option);
1028
+ }
1029
+ currentLabelSelect.disabled = !state.labels.length;
1030
+ }
1031
+
1032
+ function renderImageList() {
1033
+ imageList.innerHTML = "";
1034
+ if (!state.images.length) {
1035
+ const empty = document.createElement("div");
1036
+ empty.className = "muted";
1037
+ empty.textContent = "Brak obrazków.";
1038
+ imageList.appendChild(empty);
1039
+ return;
1040
+ }
1041
+ state.images.forEach((image, index) => {
1042
+ const item = document.createElement("div");
1043
+ item.className = "item" + (index === state.currentImageIndex ? " active" : "");
1044
+
1045
+ const top = document.createElement("div");
1046
+ top.className = "item-top";
1047
+ const title = document.createElement("strong");
1048
+ title.style.fontSize = "13px";
1049
+ title.style.wordBreak = "break-word";
1050
+ title.textContent = image.name;
1051
+ const nr = document.createElement("div");
1052
+ nr.className = "muted";
1053
+ nr.textContent = `${index + 1}/${state.images.length}`;
1054
+ top.append(title, nr);
1055
+
1056
+ const info = document.createElement("div");
1057
+ info.className = "muted";
1058
+ info.textContent = `${image.width} × ${image.height} • ${image.annotations.length} adnot.`;
1059
+
1060
+ const row = document.createElement("div");
1061
+ row.className = "row";
1062
+ const openBtn = document.createElement("button");
1063
+ openBtn.textContent = "Otwórz";
1064
+ openBtn.addEventListener("click", () => switchImage(index, state.settings?.fitOnImageLoad ?? true));
1065
+ const removeBtn = document.createElement("button");
1066
+ removeBtn.className = "danger";
1067
+ removeBtn.textContent = "Usuń";
1068
+ removeBtn.addEventListener("click", () => removeImage(index));
1069
+ row.append(openBtn, removeBtn);
1070
+
1071
+ item.append(top, info, row);
1072
+ imageList.appendChild(item);
1073
+ });
1074
+ }
1075
+
1076
+ function renderAnnotationList() {
1077
+ annotationList.innerHTML = "";
1078
+ const annotations = currentAnnotations();
1079
+ if (!annotations.length) {
1080
+ const empty = document.createElement("div");
1081
+ empty.className = "muted";
1082
+ empty.textContent = "Brak adnotacji dla bieżącego obrazka.";
1083
+ annotationList.appendChild(empty);
1084
+ return;
1085
+ }
1086
+ annotations.forEach((ann, index) => {
1087
+ const label = getLabelById(ann.labelId);
1088
+ const item = document.createElement("div");
1089
+ item.className = "item" + (ann.id === state.selectedId ? " selected" : "");
1090
+
1091
+ const top = document.createElement("div");
1092
+ top.className = "item-top";
1093
+ const chip = document.createElement("div");
1094
+ chip.className = "label-chip";
1095
+ const dot = document.createElement("span");
1096
+ dot.className = "color-dot";
1097
+ dot.style.background = label?.color || "#22c55e";
1098
+ const name = document.createElement("span");
1099
+ name.textContent = label?.name || "?";
1100
+ chip.append(dot, name);
1101
+ const nr = document.createElement("div");
1102
+ nr.className = "muted";
1103
+ nr.textContent = `#${index + 1}`;
1104
+ top.append(chip, nr);
1105
+
1106
+ const coords = document.createElement("div");
1107
+ coords.className = "muted";
1108
+ coords.textContent = `x=${Math.round(ann.x)}, y=${Math.round(ann.y)}, w=${Math.round(ann.width)}, h=${Math.round(ann.height)}`;
1109
+
1110
+ const row = document.createElement("div");
1111
+ row.className = "row";
1112
+ const selectBtn = document.createElement("button");
1113
+ selectBtn.textContent = "Zaznacz";
1114
+ selectBtn.addEventListener("click", () => {
1115
+ state.selectedId = ann.id;
1116
+ toolSelect.value = "select";
1117
+ render();
1118
+ });
1119
+ const relabelBtn = document.createElement("button");
1120
+ relabelBtn.textContent = "Ustaw aktywną";
1121
+ relabelBtn.addEventListener("click", () => {
1122
+ state.currentLabelId = ann.labelId;
1123
+ render();
1124
+ setStatus("Aktywna etykieta ustawiona z wybranej adnotacji.");
1125
+ });
1126
+ const removeBtn = document.createElement("button");
1127
+ removeBtn.className = "danger";
1128
+ removeBtn.textContent = "Usuń";
1129
+ removeBtn.addEventListener("click", () => removeAnnotation(ann.id));
1130
+ row.append(selectBtn, relabelBtn, removeBtn);
1131
+
1132
+ item.append(top, coords, row);
1133
+ annotationList.appendChild(item);
1134
+ });
1135
+ }
1136
+
1137
+ function updateStats() {
1138
+ imageCountBox.textContent = String(state.images.length);
1139
+ countBox.textContent = String(currentAnnotations().length);
1140
+ classCountBox.textContent = String(state.labels.length);
1141
+ }
1142
+
1143
+ function removeAnnotation(id) {
1144
+ const image = currentImage();
1145
+ if (!image) return;
1146
+ image.annotations = image.annotations.filter(ann => ann.id !== id);
1147
+ if (state.selectedId === id) state.selectedId = null;
1148
+ render();
1149
+ }
1150
+
1151
+ function createAnnotationFromDraft() {
1152
+ const image = currentImage();
1153
+ if (!image || !state.draft || !state.currentLabelId) return;
1154
+ if (state.draft.width < getMinBoxSize() || state.draft.height < getMinBoxSize()) {
1155
+ state.draft = null;
1156
+ state.drawing = false;
1157
+ render();
1158
+ return;
1159
+ }
1160
+ image.annotations.push({
1161
+ id: state.draft.id,
1162
+ labelId: state.currentLabelId,
1163
+ x: state.draft.x,
1164
+ y: state.draft.y,
1165
+ width: state.draft.width,
1166
+ height: state.draft.height
1167
+ });
1168
+ state.selectedId = state.draft.id;
1169
+ state.draft = null;
1170
+ state.drawing = false;
1171
+ render();
1172
+ }
1173
+
1174
+ function startInteraction(annotation, type, event, handle = null) {
1175
+ const { x, y } = getMousePos(event);
1176
+ state.interaction = {
1177
+ type,
1178
+ annotationId: annotation.id,
1179
+ handle,
1180
+ startX: x,
1181
+ startY: y,
1182
+ origin: { x: annotation.x, y: annotation.y, width: annotation.width, height: annotation.height },
1183
+ offsetX: x - annotation.x,
1184
+ offsetY: y - annotation.y
1185
+ };
1186
+ }
1187
+
1188
+ function resizeFromHandle(origin, handle, pointX, pointY) {
1189
+ let x1 = origin.x;
1190
+ let y1 = origin.y;
1191
+ let x2 = origin.x + origin.width;
1192
+ let y2 = origin.y + origin.height;
1193
+
1194
+ if (handle.includes("n")) y1 = pointY;
1195
+ if (handle.includes("s")) y2 = pointY;
1196
+ if (handle.includes("w")) x1 = pointX;
1197
+ if (handle.includes("e")) x2 = pointX;
1198
+
1199
+ const rect = normalizeRect(x1, y1, x2, y2);
1200
+ return clampRect(rect);
1201
+ }
1202
+
1203
+ function handlePointerDown(event) {
1204
+ const image = currentImage();
1205
+ if (!image) return;
1206
+ state.pointerDown = true;
1207
+ const { x, y } = getMousePos(event);
1208
+
1209
+ if (toolSelect.value === "draw") {
1210
+ if (!state.currentLabelId) {
1211
+ setStatus("Najpierw dodaj lub wybierz etykietę.");
1212
+ return;
1213
+ }
1214
+ state.drawing = true;
1215
+ state.draft = { id: uid(), x1: x, y1: y, x2: x, y2: y, x, y, width: 0, height: 0, labelId: state.currentLabelId };
1216
+ render();
1217
+ return;
1218
+ }
1219
+
1220
+ const selected = selectedAnnotation();
1221
+ if (selected) {
1222
+ const handle = hitHandle(selected, x, y);
1223
+ if (handle) {
1224
+ state.selectedId = selected.id;
1225
+ startInteraction(selected, "resize", event, handle);
1226
+ render();
1227
+ return;
1228
+ }
1229
+ }
1230
+
1231
+ const hit = findTopAnnotation(x, y);
1232
+ if (hit) {
1233
+ state.selectedId = hit.id;
1234
+ startInteraction(hit, "move", event);
1235
+ render();
1236
+ } else {
1237
+ state.selectedId = null;
1238
+ state.interaction = null;
1239
+ render();
1240
+ }
1241
+ }
1242
+
1243
+ function handlePointerMove(event) {
1244
+ if (!currentImage()) {
1245
+ canvas.style.cursor = "default";
1246
+ return;
1247
+ }
1248
+ const { x, y } = getMousePos(event);
1249
+
1250
+ if (state.drawing && state.draft) {
1251
+ state.draft.x2 = x;
1252
+ state.draft.y2 = y;
1253
+ Object.assign(state.draft, normalizeRect(state.draft.x1, state.draft.y1, x, y));
1254
+ render();
1255
+ return;
1256
+ }
1257
+
1258
+ if (state.interaction) {
1259
+ const annotation = selectedAnnotation();
1260
+ if (!annotation) {
1261
+ state.interaction = null;
1262
+ return;
1263
+ }
1264
+ if (state.interaction.type === "move") {
1265
+ const nextX = clamp(x - state.interaction.offsetX, 0, canvas.width - annotation.width);
1266
+ const nextY = clamp(y - state.interaction.offsetY, 0, canvas.height - annotation.height);
1267
+ annotation.x = nextX;
1268
+ annotation.y = nextY;
1269
+ } else if (state.interaction.type === "resize") {
1270
+ const rect = resizeFromHandle(state.interaction.origin, state.interaction.handle, clamp(x, 0, canvas.width), clamp(y, 0, canvas.height));
1271
+ annotation.x = rect.x;
1272
+ annotation.y = rect.y;
1273
+ annotation.width = rect.width;
1274
+ annotation.height = rect.height;
1275
+ }
1276
+ render();
1277
+ return;
1278
+ }
1279
+
1280
+ updateCursor(event);
1281
+ }
1282
+
1283
+ function handlePointerUp(event) {
1284
+ if (state.drawing && state.draft) {
1285
+ createAnnotationFromDraft();
1286
+ }
1287
+ if (state.interaction) {
1288
+ const annotation = selectedAnnotation();
1289
+ if (annotation && (annotation.width < getMinBoxSize() / 2 || annotation.height < getMinBoxSize() / 2)) {
1290
+ removeAnnotation(annotation.id);
1291
+ }
1292
+ state.interaction = null;
1293
+ render();
1294
+ }
1295
+ state.pointerDown = false;
1296
+ if (event) updateCursor(event);
1297
+ }
1298
+
1299
+ function applyCurrentLabelToSelection() {
1300
+ const annotation = selectedAnnotation();
1301
+ if (!annotation) {
1302
+ setStatus("Najpierw zaznacz adnotację.");
1303
+ return;
1304
+ }
1305
+ if (!state.currentLabelId) {
1306
+ setStatus("Najpierw wybierz etykietę.");
1307
+ return;
1308
+ }
1309
+ annotation.labelId = state.currentLabelId;
1310
+ render();
1311
+ const label = getLabelById(state.currentLabelId);
1312
+ setStatus(`Przypisano etykietę: ${label?.name || "?"}`);
1313
+ }
1314
+
1315
+ function exportSettingsPayload() {
1316
+ ensureSettings();
1317
+ return {
1318
+ schema: "annotator-settings",
1319
+ version: 1,
1320
+ exportedAt: new Date().toISOString(),
1321
+ settings: { ...state.settings },
1322
+ labels: state.labels.map(label => ({
1323
+ id: label.id,
1324
+ name: label.name,
1325
+ color: label.color
1326
+ })),
1327
+ currentLabelId: state.currentLabelId,
1328
+ tool: toolSelect.value || "draw"
1329
+ };
1330
+ }
1331
+
1332
+ function exportSettingsJson() {
1333
+ const payload = exportSettingsPayload();
1334
+ downloadText("annotator-settings.json", JSON.stringify(payload, null, 2));
1335
+ setStatus("Wyeksportowano ustawienia do pliku JSON.");
1336
+ }
1337
+
1338
+ function normalizeImportedLabels(labels) {
1339
+ const normalized = [];
1340
+ const seen = new Set();
1341
+ for (const item of Array.isArray(labels) ? labels : []) {
1342
+ const name = String(item?.name || "").trim();
1343
+ if (!name) continue;
1344
+ const lowered = name.toLowerCase();
1345
+ if (seen.has(lowered)) continue;
1346
+ seen.add(lowered);
1347
+ normalized.push({
1348
+ id: typeof item?.id === "string" && item.id ? item.id : uid(),
1349
+ name,
1350
+ color: typeof item?.color === "string" && item.color ? item.color : "#22c55e"
1351
+ });
1352
+ }
1353
+ return normalized;
1354
+ }
1355
+
1356
+ function importSettingsPayload(payload) {
1357
+ if (!payload || typeof payload !== "object") {
1358
+ throw new Error("Nieprawidłowy plik ustawień.");
1359
+ }
1360
+ if (payload.schema && payload.schema !== "annotator-settings") {
1361
+ throw new Error("To nie wygląda na plik ustawień annotatora.");
1362
+ }
1363
+
1364
+ const nextLabels = normalizeImportedLabels(payload.labels);
1365
+ if (!nextLabels.length) {
1366
+ throw new Error("Plik nie zawiera żadnej poprawnej etykiety.");
1367
+ }
1368
+
1369
+ state.settings = sanitizeSettings(payload.settings || {});
1370
+ state.labels = nextLabels;
1371
+
1372
+ const importedCurrentLabelId = typeof payload.currentLabelId === "string" ? payload.currentLabelId : null;
1373
+ const hasImportedCurrent = importedCurrentLabelId && state.labels.some(label => label.id === importedCurrentLabelId);
1374
+ if (hasImportedCurrent && !state.settings.autoSelectFirstLabelOnImport) {
1375
+ state.currentLabelId = importedCurrentLabelId;
1376
+ } else {
1377
+ state.currentLabelId = state.labels[0].id;
1378
+ }
1379
+
1380
+ if (payload.tool === "draw" || payload.tool === "select") {
1381
+ toolSelect.value = payload.tool;
1382
+ }
1383
+
1384
+ syncSettingsForm();
1385
+ render();
1386
+ setStatus(`Wczytano ustawienia: ${state.labels.length} etykiet.`);
1387
+ }
1388
+
1389
+ async function importSettingsFromFile(file) {
1390
+ if (!file) return;
1391
+ const text = await file.text();
1392
+ let payload;
1393
+ try {
1394
+ payload = JSON.parse(text);
1395
+ } catch (error) {
1396
+ throw new Error("Nie udało się odczytać JSON-a z pliku ustawień.");
1397
+ }
1398
+ importSettingsPayload(payload);
1399
+ }
1400
+
1401
+ function buildClassList() {
1402
+ return state.labels.map(label => label.name);
1403
+ }
1404
+
1405
+ function getClassIndexMap() {
1406
+ return new Map(state.labels.map((label, index) => [label.id, index]));
1407
+ }
1408
+
1409
+ function yoloLine(annotation, imageWidth, imageHeight, classIndex) {
1410
+ const xCenter = (annotation.x + annotation.width / 2) / imageWidth;
1411
+ const yCenter = (annotation.y + annotation.height / 2) / imageHeight;
1412
+ const width = annotation.width / imageWidth;
1413
+ const height = annotation.height / imageHeight;
1414
+ return [classIndex, round(xCenter), round(yCenter), round(width), round(height)].join(" ");
1415
+ }
1416
+
1417
+ function baseName(filename) {
1418
+ return filename.replace(/\.[^.]+$/, "");
1419
+ }
1420
+
1421
+ function safeName(name) {
1422
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
1423
+ }
1424
+
1425
+ function currentYoloText() {
1426
+ const image = currentImage();
1427
+ if (!image) return "";
1428
+ const classMap = getClassIndexMap();
1429
+ return image.annotations
1430
+ .filter(ann => classMap.has(ann.labelId))
1431
+ .map(ann => yoloLine(ann, image.width, image.height, classMap.get(ann.labelId)))
1432
+ .join("\n");
1433
+ }
1434
+
1435
+ function downloadText(filename, text) {
1436
+ const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
1437
+ const url = URL.createObjectURL(blob);
1438
+ const anchor = document.createElement("a");
1439
+ anchor.href = url;
1440
+ anchor.download = filename;
1441
+ anchor.click();
1442
+ URL.revokeObjectURL(url);
1443
+ }
1444
+
1445
+ function yamlNames(labels) {
1446
+ return "[" + labels.map(label => JSON.stringify(label)).join(", ") + "]";
1447
+ }
1448
+
1449
+ async function exportCurrentYolo() {
1450
+ const image = currentImage();
1451
+ if (!image) {
1452
+ setStatus("Najpierw wgraj obrazki.");
1453
+ return;
1454
+ }
1455
+ downloadText(`${safeName(baseName(image.name))}.txt`, currentYoloText());
1456
+ setStatus(`Wyeksportowano YOLO dla: ${image.name}`);
1457
+ }
1458
+
1459
+ async function writeTextFile(dirHandle, filename, text) {
1460
+ const fileHandle = await dirHandle.getFileHandle(filename, { create: true });
1461
+ const writable = await fileHandle.createWritable();
1462
+ await writable.write(text);
1463
+ await writable.close();
1464
+ }
1465
+
1466
+ async function writeBinaryFile(dirHandle, filename, arrayBuffer) {
1467
+ const fileHandle = await dirHandle.getFileHandle(filename, { create: true });
1468
+ const writable = await fileHandle.createWritable();
1469
+ await writable.write(arrayBuffer);
1470
+ await writable.close();
1471
+ }
1472
+
1473
+ async function exportAllYolo() {
1474
+ if (!state.images.length) {
1475
+ setStatus("Najpierw wgraj obrazki.");
1476
+ return;
1477
+ }
1478
+ const classes = buildClassList();
1479
+ const classMap = getClassIndexMap();
1480
+ const classesTxt = classes.join("\n");
1481
+ const dataYaml = [
1482
+ "path: .",
1483
+ "train: images",
1484
+ "val: images",
1485
+ `nc: ${classes.length}`,
1486
+ `names: ${yamlNames(classes)}`
1487
+ ].join("\n");
1488
+
1489
+ if (window.showDirectoryPicker) {
1490
+ try {
1491
+ const root = await window.showDirectoryPicker({ id: "yolo-export" });
1492
+ const imagesDir = await root.getDirectoryHandle("images", { create: true });
1493
+ const labelsDir = await root.getDirectoryHandle("labels", { create: true });
1494
+ await writeTextFile(root, "classes.txt", classesTxt);
1495
+ await writeTextFile(root, "data.yaml", dataYaml);
1496
+ for (const image of state.images) {
1497
+ const labelText = image.annotations
1498
+ .filter(ann => classMap.has(ann.labelId))
1499
+ .map(ann => yoloLine(ann, image.width, image.height, classMap.get(ann.labelId)))
1500
+ .join("\n");
1501
+ await writeTextFile(labelsDir, `${safeName(baseName(image.name))}.txt`, labelText);
1502
+ await writeBinaryFile(imagesDir, image.name, await image.file.arrayBuffer());
1503
+ }
1504
+ setStatus("Wyeksportowano cały zestaw YOLO do wybranego katalogu.");
1505
+ return;
1506
+ } catch (error) {
1507
+ console.error(error);
1508
+ setStatus("Eksport katalogowy anulowany lub nieudany. Uruchamiam awaryjne pobieranie plików.");
1509
+ }
1510
+ }
1511
+
1512
+ downloadText("classes.txt", classesTxt);
1513
+ downloadText("data.yaml", dataYaml);
1514
+ for (const image of state.images) {
1515
+ const labelText = image.annotations
1516
+ .filter(ann => classMap.has(ann.labelId))
1517
+ .map(ann => yoloLine(ann, image.width, image.height, classMap.get(ann.labelId)))
1518
+ .join("\n");
1519
+ downloadText(`${safeName(baseName(image.name))}.txt`, labelText);
1520
+ }
1521
+ setStatus("Wyeksportowano pliki YOLO jako osobne pobrania.");
1522
+ }
1523
+
1524
+ function registerServiceWorker() {
1525
+ if (!("serviceWorker" in navigator)) return;
1526
+ window.addEventListener("load", async () => {
1527
+ try {
1528
+ const registration = await navigator.serviceWorker.register("./sw.js");
1529
+ registration.update();
1530
+ let refreshing = false;
1531
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
1532
+ if (refreshing) return;
1533
+ refreshing = true;
1534
+ window.location.reload();
1535
+ });
1536
+ } catch (error) {
1537
+ console.error("Błąd rejestracji service workera:", error);
1538
+ }
1539
+ });
1540
+ }
1541
+
1542
+ exportSettingsBtn.addEventListener("click", exportSettingsJson);
1543
+ importSettingsBtn.addEventListener("click", () => settingsFileInput.click());
1544
+ settingsFileInput.addEventListener("change", async () => {
1545
+ const file = settingsFileInput.files?.[0];
1546
+ if (!file) return;
1547
+ try {
1548
+ await importSettingsFromFile(file);
1549
+ } catch (error) {
1550
+ console.error(error);
1551
+ setStatus(error.message || "Nie udało się wczytać ustawień.");
1552
+ } finally {
1553
+ settingsFileInput.value = "";
1554
+ }
1555
+ });
1556
+
1557
+ [minBoxSizeInput, zoomStepInput, fitOnLoadInput, autoSelectLabelInput].forEach(element => {
1558
+ element.addEventListener("change", applySettingsFromForm);
1559
+ });
1560
+
1561
+ addLabelBtn.addEventListener("click", () => {
1562
+ addLabel(newLabelInput.value, newLabelColor.value);
1563
+ newLabelInput.value = "";
1564
+ });
1565
+
1566
+ newLabelInput.addEventListener("keydown", (event) => {
1567
+ if (event.key === "Enter") {
1568
+ event.preventDefault();
1569
+ addLabelBtn.click();
1570
+ }
1571
+ });
1572
+
1573
+ currentLabelSelect.addEventListener("change", () => {
1574
+ state.currentLabelId = currentLabelSelect.value || null;
1575
+ render();
1576
+ });
1577
+
1578
+ imageInput.addEventListener("change", async (event) => {
1579
+ await addImages(event.target.files);
1580
+ imageInput.value = "";
1581
+ });
1582
+
1583
+ applyLabelBtn.addEventListener("click", applyCurrentLabelToSelection);
1584
+ prevBtn.addEventListener("click", () => state.images.length && switchImage(Math.max(0, state.currentImageIndex - 1), state.settings?.fitOnImageLoad ?? true));
1585
+ nextBtn.addEventListener("click", () => state.images.length && switchImage(Math.min(state.images.length - 1, state.currentImageIndex + 1), state.settings?.fitOnImageLoad ?? true));
1586
+ zoomRange.addEventListener("input", () => setZoom(Number(zoomRange.value) / 100));
1587
+ zoomOutBtn.addEventListener("click", () => setZoom(state.zoom - getZoomStep()));
1588
+ zoomInBtn.addEventListener("click", () => setZoom(state.zoom + getZoomStep()));
1589
+ fitBtn.addEventListener("click", fitToViewport);
1590
+ actualSizeBtn.addEventListener("click", () => setZoom(1));
1591
+
1592
+ undoBtn.addEventListener("click", () => {
1593
+ const image = currentImage();
1594
+ if (!image) return;
1595
+ image.annotations.pop();
1596
+ if (!image.annotations.find(ann => ann.id === state.selectedId)) state.selectedId = null;
1597
+ render();
1598
+ });
1599
+
1600
+ deleteBtn.addEventListener("click", () => {
1601
+ if (state.selectedId) removeAnnotation(state.selectedId);
1602
+ });
1603
+
1604
+ clearCurrentBtn.addEventListener("click", () => {
1605
+ const image = currentImage();
1606
+ if (!image) return;
1607
+ if (!confirm(`Usunąć wszystkie adnotacje z obrazka ${image.name}?`)) return;
1608
+ image.annotations = [];
1609
+ state.selectedId = null;
1610
+ render();
1611
+ setStatus(`Wyczyszczono adnotacje: ${image.name}`);
1612
+ });
1613
+
1614
+ clearAllBtn.addEventListener("click", () => {
1615
+ if (!state.images.length) return;
1616
+ if (!confirm("Usunąć wszystkie obrazki i wszystkie adnotacje?")) return;
1617
+ for (const image of state.images) {
1618
+ if (image.objectUrl) URL.revokeObjectURL(image.objectUrl);
1619
+ }
1620
+ state.images = [];
1621
+ state.currentImageIndex = -1;
1622
+ state.selectedId = null;
1623
+ state.draft = null;
1624
+ state.interaction = null;
1625
+ canvas.width = 960;
1626
+ canvas.height = 540;
1627
+ render();
1628
+ updateCanvasScale();
1629
+ setStatus("Wyczyszczono cały zestaw.");
1630
+ });
1631
+
1632
+ exportCurrentBtn.addEventListener("click", exportCurrentYolo);
1633
+ exportAllBtn.addEventListener("click", exportAllYolo);
1634
+
1635
+ canvas.addEventListener("mousedown", handlePointerDown);
1636
+ canvas.addEventListener("mousemove", handlePointerMove);
1637
+ canvas.addEventListener("mouseup", handlePointerUp);
1638
+ canvas.addEventListener("mouseleave", handlePointerUp);
1639
+ canvas.addEventListener("wheel", (event) => {
1640
+ if (!(event.ctrlKey || event.metaKey)) return;
1641
+ event.preventDefault();
1642
+ setZoom(state.zoom + (event.deltaY > 0 ? -getZoomStep() : getZoomStep()));
1643
+ }, { passive: false });
1644
+
1645
+ document.addEventListener("keydown", (event) => {
1646
+ const inTextField = event.target.matches("input[type='text'], textarea");
1647
+ if (event.key === "Delete" && state.selectedId && !inTextField) {
1648
+ removeAnnotation(state.selectedId);
1649
+ }
1650
+ if (event.key === "Escape") {
1651
+ state.selectedId = null;
1652
+ state.interaction = null;
1653
+ state.draft = null;
1654
+ state.drawing = false;
1655
+ render();
1656
+ }
1657
+ if (!inTextField && event.key === "ArrowLeft") prevBtn.click();
1658
+ if (!inTextField && event.key === "ArrowRight") nextBtn.click();
1659
+ if ((event.ctrlKey || event.metaKey) && event.key === "0") {
1660
+ event.preventDefault();
1661
+ fitToViewport();
1662
+ }
1663
+ if ((event.ctrlKey || event.metaKey) && (event.key === "+" || event.key === "=")) {
1664
+ event.preventDefault();
1665
+ setZoom(state.zoom + getZoomStep());
1666
+ }
1667
+ if ((event.ctrlKey || event.metaKey) && event.key === "-") {
1668
+ event.preventDefault();
1669
+ setZoom(state.zoom - getZoomStep());
1670
+ }
1671
+ });
1672
+
1673
+ window.addEventListener("resize", updateCanvasScale);
1674
+
1675
+ ensureDefaultLabel();
1676
+ syncSettingsForm();
1677
+ setZoom(1);
1678
+ render();
1679
+ registerServiceWorker();
1680
+ </script>
1681
+ </body>
1682
  </html>
manifest.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Annotator YOLO PWA",
3
+ "short_name": "Annotator",
4
+ "description": "Prosta aplikacja PWA do etykietowania wielu obrazów i eksportu YOLO.",
5
+ "start_url": "./",
6
+ "scope": "./",
7
+ "display": "standalone",
8
+ "background_color": "#020617",
9
+ "theme_color": "#111827",
10
+ "lang": "pl",
11
+ "icons": [
12
+ {
13
+ "src": "icon-192.png",
14
+ "sizes": "192x192",
15
+ "type": "image/png"
16
+ },
17
+ {
18
+ "src": "icon-512.png",
19
+ "sizes": "512x512",
20
+ "type": "image/png"
21
+ },
22
+ {
23
+ "src": "icon.svg",
24
+ "sizes": "any",
25
+ "type": "image/svg+xml",
26
+ "purpose": "any"
27
+ }
28
+ ]
29
+ }
sw.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CACHE_NAME = "annotator-yolo-v5";
2
+ const PRECACHE = [
3
+ "./",
4
+ "./index.html",
5
+ "./manifest.json",
6
+ "./sw.js",
7
+ "./icon.svg",
8
+ "./icon-192.png",
9
+ "./icon-512.png",
10
+ "./README.md"
11
+ ];
12
+
13
+ self.addEventListener("install", (event) => {
14
+ self.skipWaiting();
15
+ event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)));
16
+ });
17
+
18
+ self.addEventListener("activate", (event) => {
19
+ event.waitUntil((async () => {
20
+ const keys = await caches.keys();
21
+ await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)));
22
+ await self.clients.claim();
23
+ })());
24
+ });
25
+
26
+ self.addEventListener("fetch", (event) => {
27
+ const request = event.request;
28
+ if (request.method !== "GET") return;
29
+ const url = new URL(request.url);
30
+
31
+ if (request.mode === "navigate") {
32
+ event.respondWith((async () => {
33
+ try {
34
+ const fresh = await fetch(request);
35
+ const cache = await caches.open(CACHE_NAME);
36
+ cache.put("./index.html", fresh.clone());
37
+ return fresh;
38
+ } catch {
39
+ return (await caches.match(request)) || (await caches.match("./index.html"));
40
+ }
41
+ })());
42
+ return;
43
+ }
44
+
45
+ if (url.origin === location.origin) {
46
+ event.respondWith((async () => {
47
+ const cached = await caches.match(request);
48
+ if (cached) return cached;
49
+ const fresh = await fetch(request);
50
+ const cache = await caches.open(CACHE_NAME);
51
+ cache.put(request, fresh.clone());
52
+ return fresh;
53
+ })());
54
+ }
55
+ });