simple-napari-cci-annotator 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simple_napari_cci_annotator/__init__.py +7 -0
- simple_napari_cci_annotator/_widget.py +478 -0
- simple_napari_cci_annotator/_yolo_utils.py +207 -0
- simple_napari_cci_annotator/models/yolov8n.pt +0 -0
- simple_napari_cci_annotator/napari.yaml +14 -0
- simple_napari_cci_annotator-0.0.2.dist-info/METADATA +168 -0
- simple_napari_cci_annotator-0.0.2.dist-info/RECORD +11 -0
- simple_napari_cci_annotator-0.0.2.dist-info/WHEEL +5 -0
- simple_napari_cci_annotator-0.0.2.dist-info/entry_points.txt +2 -0
- simple_napari_cci_annotator-0.0.2.dist-info/licenses/LICENSE +21 -0
- simple_napari_cci_annotator-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from qtpy.QtCore import QThread, QTimer, Signal
|
|
12
|
+
from qtpy.QtWidgets import (
|
|
13
|
+
QFileDialog,
|
|
14
|
+
QHBoxLayout,
|
|
15
|
+
QLabel,
|
|
16
|
+
QLineEdit,
|
|
17
|
+
QMessageBox,
|
|
18
|
+
QPushButton,
|
|
19
|
+
QVBoxLayout,
|
|
20
|
+
QWidget,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ._yolo_utils import (
|
|
24
|
+
CCIYoloWrapper,
|
|
25
|
+
create_training_set,
|
|
26
|
+
save_vectors_to_txt,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _RetrainWorker(QThread):
|
|
31
|
+
"""Runs YOLO retraining in a background thread."""
|
|
32
|
+
|
|
33
|
+
finished = Signal(str)
|
|
34
|
+
failed = Signal(str)
|
|
35
|
+
|
|
36
|
+
def __init__(self, yolo, model_root, destination_path, parent=None):
|
|
37
|
+
super().__init__(parent)
|
|
38
|
+
self._yolo = yolo
|
|
39
|
+
self._model_root = model_root
|
|
40
|
+
self._destination_path = destination_path
|
|
41
|
+
|
|
42
|
+
def run(self):
|
|
43
|
+
model_root = self._model_root
|
|
44
|
+
corrections_root = model_root / "corrections"
|
|
45
|
+
|
|
46
|
+
if self._destination_path is not None:
|
|
47
|
+
retrain_root = self._destination_path
|
|
48
|
+
else:
|
|
49
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
50
|
+
retrain_root = model_root / f"retrained_{stamp}"
|
|
51
|
+
|
|
52
|
+
dataset_dir = retrain_root / "dataset"
|
|
53
|
+
traces_dir = retrain_root / "training_traces"
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
retrain_root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
create_training_set(
|
|
58
|
+
path_to_images=corrections_root,
|
|
59
|
+
path_to_vectors=corrections_root,
|
|
60
|
+
destination_path=dataset_dir,
|
|
61
|
+
label_names=[(0, "LABEL")],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
config = self._load_training_config(corrections_root)
|
|
65
|
+
|
|
66
|
+
self._yolo.train(
|
|
67
|
+
data_set_file=dataset_dir / "dataset.yaml",
|
|
68
|
+
image_size=config["image_size"],
|
|
69
|
+
batch=config["batch"],
|
|
70
|
+
epochs=config["epochs"],
|
|
71
|
+
patience=config["patience"],
|
|
72
|
+
project=traces_dir,
|
|
73
|
+
name="run",
|
|
74
|
+
exist_ok=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
best_model = traces_dir / "run" / "weights" / "best.pt"
|
|
78
|
+
if not best_model.exists():
|
|
79
|
+
raise FileNotFoundError(f"best.pt not found at: {best_model}")
|
|
80
|
+
|
|
81
|
+
shutil.copy2(best_model, retrain_root / "best.pt")
|
|
82
|
+
shutil.rmtree(traces_dir, ignore_errors=True)
|
|
83
|
+
except Exception as exc: # pragma: no cover - GUI runtime guard
|
|
84
|
+
self.failed.emit(f"Retrain failed: {exc}")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
self.finished.emit(f"Retrain done. New model saved in: {retrain_root}")
|
|
88
|
+
|
|
89
|
+
def _load_training_config(self, corrections_root: Path) -> dict:
|
|
90
|
+
config_file = corrections_root / "training_config.json"
|
|
91
|
+
if config_file.exists():
|
|
92
|
+
try:
|
|
93
|
+
with open(config_file) as f:
|
|
94
|
+
return json.load(f)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return {"image_size": 640, "batch": 8, "epochs": 100, "patience": 30}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SimpleCciAnnotatorQWidget(QWidget):
|
|
101
|
+
"""Minimal YOLO bbox annotator flow for napari.
|
|
102
|
+
|
|
103
|
+
Workflow:
|
|
104
|
+
1) Select model path (.pt) and load model
|
|
105
|
+
2) Predict bboxes on the active image
|
|
106
|
+
3) User edits shapes
|
|
107
|
+
4) Add correction (image + edited labels)
|
|
108
|
+
5) Retrain to produce a new model folder
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
PRED_LAYER_NAME = "yolo_bboxes"
|
|
112
|
+
|
|
113
|
+
def __init__(self, napari_viewer):
|
|
114
|
+
super().__init__()
|
|
115
|
+
self.napari_viewer = napari_viewer
|
|
116
|
+
self.setWindowTitle("Simple CCI Annotator")
|
|
117
|
+
|
|
118
|
+
self._yolo: CCIYoloWrapper | None = None
|
|
119
|
+
self._model_path: Path | None = None
|
|
120
|
+
self._destination_path: Path | None = None
|
|
121
|
+
self._retrain_worker: _RetrainWorker | None = None
|
|
122
|
+
|
|
123
|
+
self._spinner_timer = QTimer(self)
|
|
124
|
+
self._spinner_timer.setInterval(400)
|
|
125
|
+
self._spinner_timer.timeout.connect(self._tick_spinner)
|
|
126
|
+
self._spinner_frames = ["Retraining .", "Retraining ..", "Retraining ...", "Retraining"]
|
|
127
|
+
self._spinner_index = 0
|
|
128
|
+
|
|
129
|
+
self._model_path_input = QLineEdit()
|
|
130
|
+
self._model_path_input.setPlaceholderText("Path to YOLO model (.pt) or model folder")
|
|
131
|
+
|
|
132
|
+
browse_button = QPushButton("Browse")
|
|
133
|
+
browse_button.clicked.connect(self._on_browse_model)
|
|
134
|
+
|
|
135
|
+
load_button = QPushButton("Load model")
|
|
136
|
+
load_button.clicked.connect(self._on_load_model)
|
|
137
|
+
|
|
138
|
+
predict_button = QPushButton("Predict")
|
|
139
|
+
predict_button.clicked.connect(self._on_predict)
|
|
140
|
+
|
|
141
|
+
self.destination_path_input = QLineEdit()
|
|
142
|
+
self.destination_path_input.setPlaceholderText("Path to save retrained model")
|
|
143
|
+
|
|
144
|
+
browse_destination_button = QPushButton("Browse")
|
|
145
|
+
browse_destination_button.clicked.connect(self._on_browse_destination)
|
|
146
|
+
|
|
147
|
+
add_correction_button = QPushButton("Add correction")
|
|
148
|
+
add_correction_button.clicked.connect(self._on_add_correction)
|
|
149
|
+
|
|
150
|
+
self._retrain_button = QPushButton("Retrain")
|
|
151
|
+
self._retrain_button.clicked.connect(self._on_retrain)
|
|
152
|
+
|
|
153
|
+
row_model = QHBoxLayout()
|
|
154
|
+
row_model.addWidget(QLabel("Model"))
|
|
155
|
+
row_model.addWidget(self._model_path_input)
|
|
156
|
+
row_model.addWidget(browse_button)
|
|
157
|
+
|
|
158
|
+
row_actions = QHBoxLayout()
|
|
159
|
+
row_actions.addWidget(load_button)
|
|
160
|
+
row_actions.addWidget(predict_button)
|
|
161
|
+
|
|
162
|
+
row_destination = QHBoxLayout()
|
|
163
|
+
row_destination.addWidget(QLabel("Destination"))
|
|
164
|
+
row_destination.addWidget(self.destination_path_input)
|
|
165
|
+
row_destination.addWidget(browse_destination_button)
|
|
166
|
+
|
|
167
|
+
row_train = QHBoxLayout()
|
|
168
|
+
row_train.addWidget(add_correction_button)
|
|
169
|
+
row_train.addWidget(self._retrain_button)
|
|
170
|
+
#row_train.addStretch(1)
|
|
171
|
+
|
|
172
|
+
layout = QVBoxLayout()
|
|
173
|
+
layout.addLayout(row_model)
|
|
174
|
+
layout.addLayout(row_actions)
|
|
175
|
+
layout.addLayout(row_destination)
|
|
176
|
+
layout.addLayout(row_train)
|
|
177
|
+
layout.addStretch(1)
|
|
178
|
+
self.setLayout(layout)
|
|
179
|
+
|
|
180
|
+
def _show_info(self, text: str) -> None:
|
|
181
|
+
QMessageBox.information(self, "Simple CCI Annotator", text)
|
|
182
|
+
|
|
183
|
+
def _show_error(self, text: str) -> None:
|
|
184
|
+
QMessageBox.critical(self, "Simple CCI Annotator", text)
|
|
185
|
+
|
|
186
|
+
def _on_browse_model(self) -> None:
|
|
187
|
+
model_dir = QFileDialog.getExistingDirectory(
|
|
188
|
+
self,
|
|
189
|
+
"Select model folder (.pt will be loaded or yolov8n.pt will be copied)"
|
|
190
|
+
)
|
|
191
|
+
if model_dir:
|
|
192
|
+
self._model_path_input.setText(model_dir)
|
|
193
|
+
|
|
194
|
+
def _on_browse_destination(self) -> None:
|
|
195
|
+
destination_dir = QFileDialog.getExistingDirectory(self, "Select destination for retrained model")
|
|
196
|
+
if destination_dir:
|
|
197
|
+
self.destination_path_input.setText(destination_dir)
|
|
198
|
+
|
|
199
|
+
def _on_load_model(self) -> None:
|
|
200
|
+
model_input = self._model_path_input.text().strip()
|
|
201
|
+
if not model_input:
|
|
202
|
+
self._show_error("Model path cannot be empty. Select a .pt file or a folder.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
model_path_input = Path(model_input)
|
|
206
|
+
if not model_path_input.exists():
|
|
207
|
+
self._show_error("Model path does not exist.")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
model_path: Path
|
|
211
|
+
copied_default_model = False
|
|
212
|
+
|
|
213
|
+
if model_path_input.is_file():
|
|
214
|
+
if model_path_input.suffix.lower() != ".pt":
|
|
215
|
+
self._show_error("Select a valid .pt model file or a folder.")
|
|
216
|
+
return
|
|
217
|
+
model_path = model_path_input
|
|
218
|
+
elif model_path_input.is_dir():
|
|
219
|
+
pt_files = sorted(model_path_input.glob("*.pt"))
|
|
220
|
+
if pt_files:
|
|
221
|
+
model_path = pt_files[0]
|
|
222
|
+
else:
|
|
223
|
+
default_model_source = Path(__file__).parent / "models" / "yolov8n.pt"
|
|
224
|
+
if not default_model_source.exists():
|
|
225
|
+
self._show_error("No .pt found in selected folder, and bundled yolov8n.pt is missing.")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
model_path = model_path_input / "yolov8n.pt"
|
|
229
|
+
shutil.copy2(default_model_source, model_path)
|
|
230
|
+
copied_default_model = True
|
|
231
|
+
else:
|
|
232
|
+
self._show_error("Select a valid .pt model file or a folder.")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
self._yolo = CCIYoloWrapper(str(model_path))
|
|
237
|
+
self._model_path = model_path
|
|
238
|
+
except Exception as exc: # pragma: no cover - GUI runtime guard
|
|
239
|
+
self._show_error(f"Could not load model: {exc}")
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if copied_default_model:
|
|
243
|
+
self._show_info(f"No .pt model was found in the folder. Copied bundled model to: {model_path}")
|
|
244
|
+
|
|
245
|
+
self._show_info(f"Model loaded: {model_path.name}")
|
|
246
|
+
|
|
247
|
+
def _get_active_image_layer(self):
|
|
248
|
+
layer = self.napari_viewer.layers.selection.active
|
|
249
|
+
if layer is None:
|
|
250
|
+
self._show_error("Select an image layer first.")
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
if getattr(layer, "data", None) is None:
|
|
254
|
+
self._show_error("Active layer has no image data.")
|
|
255
|
+
return None
|
|
256
|
+
return layer
|
|
257
|
+
|
|
258
|
+
def _is_image_layer(self, layer) -> bool:
|
|
259
|
+
return layer is not None and layer.__class__.__name__.lower().endswith("image")
|
|
260
|
+
|
|
261
|
+
def _get_single_image_layer(self):
|
|
262
|
+
image_layers = [layer for layer in self.napari_viewer.layers if self._is_image_layer(layer)]
|
|
263
|
+
if len(image_layers) == 0:
|
|
264
|
+
self._show_error("No image layer found.")
|
|
265
|
+
return None
|
|
266
|
+
if len(image_layers) > 1:
|
|
267
|
+
self._show_error("Multiple image layers found. Keep only one image layer before adding a correction.")
|
|
268
|
+
return None
|
|
269
|
+
return image_layers[0]
|
|
270
|
+
|
|
271
|
+
def _get_layer_by_name(self, name: str):
|
|
272
|
+
for layer in self.napari_viewer.layers:
|
|
273
|
+
if getattr(layer, "name", None) == name:
|
|
274
|
+
return layer
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def _is_shapes_layer(self, layer) -> bool:
|
|
278
|
+
return layer is not None and layer.__class__.__name__.lower().endswith("shapes")
|
|
279
|
+
|
|
280
|
+
def _on_predict(self) -> None:
|
|
281
|
+
if self._yolo is None:
|
|
282
|
+
self._show_error("Load a model first.")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
image_layer = self._get_active_image_layer()
|
|
286
|
+
if image_layer is None:
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
image_data = np.asarray(image_layer.data)
|
|
290
|
+
if image_data.ndim < 2:
|
|
291
|
+
self._show_error("Unsupported image shape.")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
prediction = self._yolo.predict(image_data)
|
|
296
|
+
boxes = prediction[0].boxes.xyxy.cpu().numpy() if len(prediction) else np.empty((0, 4))
|
|
297
|
+
except Exception as exc: # pragma: no cover - GUI runtime guard
|
|
298
|
+
self._show_error(f"Prediction failed: {exc}")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
rects = []
|
|
302
|
+
for x1, y1, x2, y2 in boxes:
|
|
303
|
+
rects.append(np.array([[y1, x1], [y1, x2], [y2, x2], [y2, x1]], dtype=float))
|
|
304
|
+
|
|
305
|
+
existing = self._get_layer_by_name(self.PRED_LAYER_NAME)
|
|
306
|
+
if existing is not None:
|
|
307
|
+
self.napari_viewer.layers.remove(existing)
|
|
308
|
+
|
|
309
|
+
self.napari_viewer.add_shapes(
|
|
310
|
+
rects,
|
|
311
|
+
name=self.PRED_LAYER_NAME,
|
|
312
|
+
shape_type="rectangle",
|
|
313
|
+
edge_width=2,
|
|
314
|
+
edge_color="yellow",
|
|
315
|
+
face_color="transparent",
|
|
316
|
+
)
|
|
317
|
+
self._show_info(f"Prediction done: {len(rects)} bbox(es). Edit them, then click Add correction.")
|
|
318
|
+
|
|
319
|
+
def _find_shapes_layer(self):
|
|
320
|
+
layer = self._get_layer_by_name(self.PRED_LAYER_NAME)
|
|
321
|
+
if self._is_shapes_layer(layer):
|
|
322
|
+
return layer
|
|
323
|
+
|
|
324
|
+
selected = self.napari_viewer.layers.selection.active
|
|
325
|
+
if self._is_shapes_layer(selected):
|
|
326
|
+
return selected
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def _to_safe_stem(self, name: str) -> str:
|
|
330
|
+
stem = re.sub(r"[^a-zA-Z0-9._-]+", "_", name.strip())
|
|
331
|
+
return stem or "image"
|
|
332
|
+
|
|
333
|
+
def _get_model_root(self) -> Path | None:
|
|
334
|
+
if self._model_path is None:
|
|
335
|
+
return None
|
|
336
|
+
return self._model_path.parent
|
|
337
|
+
|
|
338
|
+
def _as_rgb_uint8(self, image: np.ndarray) -> np.ndarray:
|
|
339
|
+
if image.ndim == 2:
|
|
340
|
+
image = np.stack([image] * 3, axis=-1)
|
|
341
|
+
elif image.ndim == 3 and image.shape[-1] > 3:
|
|
342
|
+
image = image[..., :3]
|
|
343
|
+
|
|
344
|
+
if image.dtype != np.uint8:
|
|
345
|
+
image = np.clip(image, 0, 255).astype(np.uint8)
|
|
346
|
+
return image
|
|
347
|
+
|
|
348
|
+
def _create_training_config(self, corrections_root: Path, image_size: int | None = None) -> None:
|
|
349
|
+
"""Create a training configuration JSON file in the corrections directory.
|
|
350
|
+
Args:
|
|
351
|
+
corrections_root: Path to the corrections directory
|
|
352
|
+
image_size: Optional image size to use. If not provided, defaults to 640.
|
|
353
|
+
"""
|
|
354
|
+
if image_size is None:
|
|
355
|
+
image_size = 640
|
|
356
|
+
|
|
357
|
+
config = {
|
|
358
|
+
"image_size": image_size,
|
|
359
|
+
"batch": 8,
|
|
360
|
+
"epochs": 100,
|
|
361
|
+
"patience": 30,
|
|
362
|
+
}
|
|
363
|
+
config_file = corrections_root / "training_config.json"
|
|
364
|
+
with open(config_file, 'w') as f:
|
|
365
|
+
json.dump(config, f, indent=2)
|
|
366
|
+
|
|
367
|
+
def _load_training_config(self, corrections_root: Path) -> dict:
|
|
368
|
+
"""Load training configuration from JSON file, return defaults if not found."""
|
|
369
|
+
config_file = corrections_root / "training_config.json"
|
|
370
|
+
if config_file.exists():
|
|
371
|
+
try:
|
|
372
|
+
with open(config_file) as f:
|
|
373
|
+
return json.load(f)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# Return defaults if file doesn't exist or can't be read
|
|
378
|
+
return {
|
|
379
|
+
"image_size": 640,
|
|
380
|
+
"batch": 8,
|
|
381
|
+
"epochs": 100,
|
|
382
|
+
"patience": 30,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
def _on_add_correction(self) -> None:
|
|
386
|
+
destination_text = self.destination_path_input.text().strip()
|
|
387
|
+
self._destination_path = Path(destination_text) if destination_text else None
|
|
388
|
+
model_root = self._get_model_root()
|
|
389
|
+
if model_root is None:
|
|
390
|
+
self._show_error("Load a model first.")
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
image_layer = self._get_single_image_layer()
|
|
394
|
+
if image_layer is None:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
shapes_layer = self._find_shapes_layer()
|
|
398
|
+
if shapes_layer is None:
|
|
399
|
+
self._show_error(f"No shapes layer found. Use '{self.PRED_LAYER_NAME}' or select a shapes layer.")
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
image_data = self._as_rgb_uint8(np.asarray(image_layer.data))
|
|
403
|
+
h, w = image_data.shape[:2]
|
|
404
|
+
if h <= 0 or w <= 0:
|
|
405
|
+
self._show_error("Image has invalid size.")
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
corrections_root = model_root / "corrections"
|
|
409
|
+
corrections_root.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
|
|
411
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
412
|
+
stem = f"{self._to_safe_stem(getattr(image_layer, 'name', 'image'))}_{stamp}"
|
|
413
|
+
image_out = corrections_root / f"{stem}.png"
|
|
414
|
+
label_out = corrections_root / f"{stem}.txt"
|
|
415
|
+
|
|
416
|
+
vectors: list[tuple[int, list[tuple[float, float]]]] = []
|
|
417
|
+
for shape in np.asarray(shapes_layer.data, dtype=float):
|
|
418
|
+
pts: list[tuple[float, float]] = []
|
|
419
|
+
for y, x in shape:
|
|
420
|
+
xn = float(np.clip(x / w, 0.0, 1.0))
|
|
421
|
+
yn = float(np.clip(y / h, 0.0, 1.0))
|
|
422
|
+
pts.append((xn, yn))
|
|
423
|
+
if pts:
|
|
424
|
+
vectors.append((0, pts))
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
Image.fromarray(image_data).save(image_out)
|
|
428
|
+
save_vectors_to_txt(vectors, label_out)
|
|
429
|
+
# Use the larger image dimension as the training image size
|
|
430
|
+
img_size = max(h, w)
|
|
431
|
+
self._create_training_config(corrections_root, image_size=img_size)
|
|
432
|
+
except Exception as exc: # pragma: no cover - GUI runtime guard
|
|
433
|
+
self._show_error(f"Could not save correction: {exc}")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
self._show_info(f"Saved correction to: {corrections_root}")
|
|
437
|
+
|
|
438
|
+
def _on_retrain(self) -> None:
|
|
439
|
+
if self._yolo is None or self._model_path is None:
|
|
440
|
+
self._show_error("Load a model first.")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
model_root = self._model_path.parent
|
|
444
|
+
corrections_root = model_root / "corrections"
|
|
445
|
+
|
|
446
|
+
if not corrections_root.exists():
|
|
447
|
+
self._show_error("No corrections found. Add at least one correction first.")
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
self._retrain_button.setEnabled(False)
|
|
451
|
+
self._spinner_index = 0
|
|
452
|
+
self._spinner_timer.start()
|
|
453
|
+
|
|
454
|
+
self._retrain_worker = _RetrainWorker(
|
|
455
|
+
yolo=self._yolo,
|
|
456
|
+
model_root=model_root,
|
|
457
|
+
destination_path=self._destination_path,
|
|
458
|
+
parent=self,
|
|
459
|
+
)
|
|
460
|
+
self._retrain_worker.finished.connect(self._on_retrain_done)
|
|
461
|
+
self._retrain_worker.failed.connect(self._on_retrain_error)
|
|
462
|
+
self._retrain_worker.start()
|
|
463
|
+
|
|
464
|
+
def _tick_spinner(self) -> None:
|
|
465
|
+
self._retrain_button.setText(self._spinner_frames[self._spinner_index % len(self._spinner_frames)])
|
|
466
|
+
self._spinner_index += 1
|
|
467
|
+
|
|
468
|
+
def _on_retrain_done(self, message: str) -> None:
|
|
469
|
+
self._spinner_timer.stop()
|
|
470
|
+
self._retrain_button.setText("Retrain")
|
|
471
|
+
self._retrain_button.setEnabled(True)
|
|
472
|
+
self._show_info(message)
|
|
473
|
+
|
|
474
|
+
def _on_retrain_error(self, message: str) -> None:
|
|
475
|
+
self._spinner_timer.stop()
|
|
476
|
+
self._retrain_button.setText("Retrain")
|
|
477
|
+
self._retrain_button.setEnabled(True)
|
|
478
|
+
self._show_error(message)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _points_to_yolo_xywh(points: list[tuple[float, float]]) -> tuple[float, float, float, float] | None:
|
|
6
|
+
"""Convert normalized polygon points to normalized YOLO xywh box."""
|
|
7
|
+
if not points:
|
|
8
|
+
return None
|
|
9
|
+
|
|
10
|
+
xs = [x for x, _ in points]
|
|
11
|
+
ys = [y for _, y in points]
|
|
12
|
+
|
|
13
|
+
x_min = min(xs)
|
|
14
|
+
x_max = max(xs)
|
|
15
|
+
y_min = min(ys)
|
|
16
|
+
y_max = max(ys)
|
|
17
|
+
|
|
18
|
+
x_center = (x_min + x_max) / 2.0
|
|
19
|
+
y_center = (y_min + y_max) / 2.0
|
|
20
|
+
width = x_max - x_min
|
|
21
|
+
height = y_max - y_min
|
|
22
|
+
|
|
23
|
+
if width <= 0 or height <= 0:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
x_center = min(max(x_center, 0.0), 1.0)
|
|
27
|
+
y_center = min(max(y_center, 0.0), 1.0)
|
|
28
|
+
width = min(max(width, 0.0), 1.0)
|
|
29
|
+
height = min(max(height, 0.0), 1.0)
|
|
30
|
+
return x_center, y_center, width, height
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_vectors_to_txt(vectors: list[tuple[int, list[tuple[float, float]]]], file_path: Path) -> None:
|
|
34
|
+
"""Save vectors to a text file in YOLO detection format: class x_center y_center width height."""
|
|
35
|
+
with open(file_path, 'w') as f:
|
|
36
|
+
for class_type, points in vectors:
|
|
37
|
+
bbox = _points_to_yolo_xywh(points)
|
|
38
|
+
if bbox is None:
|
|
39
|
+
continue
|
|
40
|
+
x_center, y_center, width, height = bbox
|
|
41
|
+
f.write(f"{class_type} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def convert_txt_labels_to_yolo_xywh(path: Path) -> int:
|
|
45
|
+
"""Convert label txt files from polygon format to YOLO xywh format in-place.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
int: Number of files rewritten.
|
|
49
|
+
"""
|
|
50
|
+
path = Path(path)
|
|
51
|
+
txt_files = [path] if path.is_file() else sorted(path.rglob("*.txt"))
|
|
52
|
+
converted = 0
|
|
53
|
+
|
|
54
|
+
for txt_file in txt_files:
|
|
55
|
+
if txt_file.name == "dataset.yaml":
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
new_lines: list[str] = []
|
|
59
|
+
has_polygon_line = False
|
|
60
|
+
|
|
61
|
+
for raw_line in txt_file.read_text(encoding="utf-8").splitlines():
|
|
62
|
+
line = raw_line.strip()
|
|
63
|
+
if not line:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
parts = line.split()
|
|
67
|
+
if len(parts) == 5:
|
|
68
|
+
new_lines.append(line)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if len(parts) < 7 or len(parts[1:]) % 2 != 0:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
class_id = parts[0]
|
|
75
|
+
coords = [float(v) for v in parts[1:]]
|
|
76
|
+
points = list(zip(coords[0::2], coords[1::2]))
|
|
77
|
+
bbox = _points_to_yolo_xywh(points)
|
|
78
|
+
if bbox is None:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
has_polygon_line = True
|
|
82
|
+
x_center, y_center, width, height = bbox
|
|
83
|
+
new_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
|
|
84
|
+
|
|
85
|
+
if has_polygon_line:
|
|
86
|
+
txt_file.write_text("\n".join(new_lines) + ("\n" if new_lines else ""), encoding="utf-8")
|
|
87
|
+
converted += 1
|
|
88
|
+
|
|
89
|
+
return converted
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_training_set(path_to_images: Path, path_to_vectors: Path, destination_path: Path, label_names: list[tuple[int, str]]) -> None:
|
|
93
|
+
"""Create a YOLO training set by copying images and annotations to the destination path.
|
|
94
|
+
Args:
|
|
95
|
+
path_to_images (Path): Path to the directory containing images.
|
|
96
|
+
path_to_annotations (Path): Path to the directory containing annotation files.
|
|
97
|
+
destination_path (Path): Path to the destination directory for the training set.
|
|
98
|
+
label_names (list[tuple[int, str]]): List of tuples defining class indices and their names.
|
|
99
|
+
"""
|
|
100
|
+
# Create destination directories
|
|
101
|
+
images_dest_path = destination_path / Path("images")
|
|
102
|
+
labels_dest_path = destination_path / Path("labels")
|
|
103
|
+
|
|
104
|
+
if images_dest_path.exists():
|
|
105
|
+
shutil.rmtree(images_dest_path)
|
|
106
|
+
if labels_dest_path.exists():
|
|
107
|
+
shutil.rmtree(labels_dest_path)
|
|
108
|
+
|
|
109
|
+
images_dest_val = images_dest_path / Path("val")
|
|
110
|
+
images_dest_train = images_dest_path / Path("train")
|
|
111
|
+
|
|
112
|
+
labels_dest_val = labels_dest_path / Path("val")
|
|
113
|
+
labels_dest_train = labels_dest_path / Path("train")
|
|
114
|
+
|
|
115
|
+
images_dest_val.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
images_dest_train.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
labels_dest_val.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
labels_dest_train.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
img_exts = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}
|
|
121
|
+
vec_exts = {".txt"}
|
|
122
|
+
|
|
123
|
+
images = sorted([p for p in path_to_images.iterdir() if p.suffix.lower() in img_exts])
|
|
124
|
+
label_vectors = sorted([p for p in path_to_vectors.iterdir() if p.suffix.lower() in vec_exts])
|
|
125
|
+
|
|
126
|
+
labels_by_stem = {}
|
|
127
|
+
for label_path in label_vectors:
|
|
128
|
+
label_stem = label_path.stem
|
|
129
|
+
if label_stem.endswith("training"):
|
|
130
|
+
label_stem = label_stem[:-len("training")]
|
|
131
|
+
labels_by_stem[label_stem] = label_path
|
|
132
|
+
|
|
133
|
+
for idx, img_path in enumerate(images):
|
|
134
|
+
if idx % 2 == 0:
|
|
135
|
+
image_destination = images_dest_train / img_path.name
|
|
136
|
+
label_destination_dir = labels_dest_train
|
|
137
|
+
else:
|
|
138
|
+
image_destination = images_dest_val / img_path.name
|
|
139
|
+
label_destination_dir = labels_dest_val
|
|
140
|
+
|
|
141
|
+
shutil.copy2(img_path, image_destination)
|
|
142
|
+
|
|
143
|
+
label_path = labels_by_stem.get(img_path.stem)
|
|
144
|
+
if label_path is not None:
|
|
145
|
+
shutil.copy2(label_path, label_destination_dir / f"{img_path.stem}.txt")
|
|
146
|
+
|
|
147
|
+
dataset_file = destination_path / Path("dataset.yaml")
|
|
148
|
+
with open(dataset_file, 'w') as dataset_file:
|
|
149
|
+
dataset_file.write("train: ./images/train/\n")
|
|
150
|
+
dataset_file.write("val: ./images/val/\n")
|
|
151
|
+
dataset_file.write("names:\n")
|
|
152
|
+
for class_index, class_name in label_names:
|
|
153
|
+
dataset_file.write(f" {class_index}: {class_name}\n")
|
|
154
|
+
|
|
155
|
+
dataset_file.close()
|
|
156
|
+
|
|
157
|
+
class CCIYoloWrapper:
|
|
158
|
+
|
|
159
|
+
def __init__(self, model_name_or_path: str = "yolov8n.pt"):
|
|
160
|
+
self.model_name = ""
|
|
161
|
+
self.res = None
|
|
162
|
+
self.model = self._create_model(model_name_or_path)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _create_model(model_name_or_path):
|
|
166
|
+
# Defer ultralytics/torch import so package import and pure helper tests
|
|
167
|
+
# do not fail on systems without a working torch runtime.
|
|
168
|
+
try:
|
|
169
|
+
from ultralytics import YOLO
|
|
170
|
+
except Exception as exc: # pragma: no cover - runtime environment guard
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
"Failed to import ultralytics/torch. Install a compatible CPU build "
|
|
173
|
+
"for this platform to run model inference or training."
|
|
174
|
+
) from exc
|
|
175
|
+
return YOLO(model_name_or_path)
|
|
176
|
+
|
|
177
|
+
# @classmethod
|
|
178
|
+
# def load_model_by_name(cls, model_name: str, basedir: str = 'models'):
|
|
179
|
+
# return cls(yolomodel(None, name=model_name, basedir=basedir), model_name=model_name, basedir=basedir)
|
|
180
|
+
|
|
181
|
+
# @classmethod
|
|
182
|
+
# def new_model(cls, config=yolo.models.Config2D, model_name: str = "latest", basedir: str = 'models'):
|
|
183
|
+
# return cls(yolomodel(config, name=model_name, basedir=basedir), model_name=model_name, basedir=basedir)
|
|
184
|
+
|
|
185
|
+
def load_model(self, weights_path: Path):
|
|
186
|
+
self.model = self._create_model(weights_path)
|
|
187
|
+
|
|
188
|
+
def predict(self, img):
|
|
189
|
+
return self.model(img)
|
|
190
|
+
|
|
191
|
+
def train(self, data_set_file: Path, image_size, batch=8, epochs=300, patience=100, ** kwargs):
|
|
192
|
+
data_set_file = Path(data_set_file)
|
|
193
|
+
if data_set_file.is_dir():
|
|
194
|
+
data_set_file = data_set_file / "dataset.yaml"
|
|
195
|
+
|
|
196
|
+
if "batch_size" in kwargs:
|
|
197
|
+
batch_size = kwargs.pop("batch_size")
|
|
198
|
+
if batch != 8 and batch != batch_size:
|
|
199
|
+
raise ValueError("Pass either 'batch' or 'batch_size', not both with different values.")
|
|
200
|
+
batch = batch_size
|
|
201
|
+
|
|
202
|
+
self.res = self.model.train(data=data_set_file, batch=batch, imgsz=image_size, epochs=epochs, patience=patience, **kwargs)
|
|
203
|
+
return self.res
|
|
204
|
+
|
|
205
|
+
def get_number_of_run_epochs(self):
|
|
206
|
+
trainer = getattr(self.model, "trainer", None)
|
|
207
|
+
return getattr(trainer, "epoch", None)
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: simple-napari-cci-annotator
|
|
2
|
+
display_name: CCI Annotator
|
|
3
|
+
# use 'hidden' to remove plugin from napari hub search results
|
|
4
|
+
visibility: public
|
|
5
|
+
# see https://napari.org/stable/plugins/technical_references/manifest.html#fields for valid categories
|
|
6
|
+
categories: ["Annotation", "Segmentation"]
|
|
7
|
+
contributions:
|
|
8
|
+
commands:
|
|
9
|
+
- id: simple-napari-cci-annotator.open_widget
|
|
10
|
+
python_name: simple_napari_cci_annotator:SimpleCciAnnotatorQWidget
|
|
11
|
+
title: Simple CCI annotator plugin
|
|
12
|
+
widgets:
|
|
13
|
+
- command: simple-napari-cci-annotator.open_widget
|
|
14
|
+
display_name: Simple CCI Annotator Plugin
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple-napari-cci-annotator
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Plugin to easly test a yolo model and retrain it
|
|
5
|
+
Author: Simon Leclerc
|
|
6
|
+
Author-email: simon.leclerc@gu.se
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 LECLERC Simon
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
30
|
+
Classifier: Framework :: napari
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Classifier: Programming Language :: Python
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
40
|
+
Requires-Python: >=3.9
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: LICENSE
|
|
43
|
+
Requires-Dist: numpy
|
|
44
|
+
Requires-Dist: qtpy
|
|
45
|
+
Requires-Dist: ultralytics
|
|
46
|
+
Requires-Dist: pillow
|
|
47
|
+
Provides-Extra: all
|
|
48
|
+
Requires-Dist: napari[all]; extra == "all"
|
|
49
|
+
Provides-Extra: testing
|
|
50
|
+
Requires-Dist: tox; extra == "testing"
|
|
51
|
+
Requires-Dist: pytest; extra == "testing"
|
|
52
|
+
Requires-Dist: pytest-cov; extra == "testing"
|
|
53
|
+
Requires-Dist: pytest-qt; extra == "testing"
|
|
54
|
+
Requires-Dist: napari; extra == "testing"
|
|
55
|
+
Requires-Dist: pyqt5; extra == "testing"
|
|
56
|
+
Dynamic: license-file
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
[](https://github.com/CCI-GU-Sweden/simple-napari-cci-annotator/blob/main/LICENSE)
|
|
60
|
+
[](https://python.org)
|
|
61
|
+
[](https://github.com/CCI-GU-Sweden/simple-napari-cci-annotator/actions)
|
|
62
|
+
|
|
63
|
+
# Simple napari annotator
|
|
64
|
+
|
|
65
|
+
Minimal napari plugin for YOLO bbox detection + quick correction + retraining.
|
|
66
|
+
|
|
67
|
+
No dataset browser, no extra workflow logic. User provides the image in napari.
|
|
68
|
+
|
|
69
|
+
## UI flow
|
|
70
|
+
|
|
71
|
+
1. Path to model (`.pt`) or model folder
|
|
72
|
+
2. `Load model`
|
|
73
|
+
3. `Predict`
|
|
74
|
+
4. Optional destination folder for retrained model (browse or type)
|
|
75
|
+
5. Edit boxes in napari shapes layer (`yolo_bboxes`)
|
|
76
|
+
6. `Add correction`
|
|
77
|
+
7. `Retrain`
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
## Starting from scratch
|
|
82
|
+
|
|
83
|
+
The model input supports either:
|
|
84
|
+
|
|
85
|
+
- A `.pt` model file, or
|
|
86
|
+
- A folder path.
|
|
87
|
+
|
|
88
|
+
Folder behavior on `Load model`:
|
|
89
|
+
|
|
90
|
+
- If the folder already contains one or more `.pt` files, the first one is loaded.
|
|
91
|
+
- If the folder contains no `.pt` file, the plugin copies the bundled `yolov8n.pt` into that folder and loads it.
|
|
92
|
+
- Empty model input is invalid and will show an error.
|
|
93
|
+
|
|
94
|
+
If you do not have a model yet, start from a pretrained YOLOv8 nano checkpoint and use it as your initial file:
|
|
95
|
+
|
|
96
|
+
- Direct download: <https://github.com/ultralytics/assets/releases/latest/download/yolov8n.pt>
|
|
97
|
+
- Model overview: <https://docs.ultralytics.com/models/yolov8/>
|
|
98
|
+
|
|
99
|
+
After downloading, select that `yolov8n.pt` file in the Model field and continue with the correction/retrain loop.
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
|
|
103
|
+
## Assumptions
|
|
104
|
+
|
|
105
|
+
- Input image is already RGB 8-bit (or compatible with clipping/conversion).
|
|
106
|
+
- Single class (`0: LABEL`) for now.
|
|
107
|
+
- Exactly one image layer should be present when using `Add correction`.
|
|
108
|
+
|
|
109
|
+

|
|
110
|
+
|
|
111
|
+
## Folder behavior
|
|
112
|
+
|
|
113
|
+
Given a model path like:
|
|
114
|
+
|
|
115
|
+
`.../my_model/best.pt`
|
|
116
|
+
|
|
117
|
+
The plugin uses `.../my_model` as root.
|
|
118
|
+
|
|
119
|
+
### Add correction
|
|
120
|
+
|
|
121
|
+
Each click saves:
|
|
122
|
+
|
|
123
|
+
- Image to `my_model/corrections/<image_name>_<timestamp>.png`
|
|
124
|
+
- Labels to `my_model/corrections/<image_name>_<timestamp>.txt`
|
|
125
|
+
- Training config to `my_model/corrections/training_config.json` (created/updated)
|
|
126
|
+
|
|
127
|
+
Image layer behavior:
|
|
128
|
+
|
|
129
|
+
- If no image layer exists, `Add correction` shows an error.
|
|
130
|
+
- If more than one image layer exists, `Add correction` shows an error.
|
|
131
|
+
- If exactly one image layer exists, that image layer is used for saving correction image data.
|
|
132
|
+
|
|
133
|
+
`training_config.json` defaults:
|
|
134
|
+
|
|
135
|
+
- `image_size`: prefilled from the current image size using `max(height, width)`
|
|
136
|
+
- `batch`: `8`
|
|
137
|
+
- `epochs`: `100`
|
|
138
|
+
- `patience`: `30`
|
|
139
|
+
|
|
140
|
+
You can edit this file before clicking `Retrain`.
|
|
141
|
+
|
|
142
|
+
Label format is YOLO detection:
|
|
143
|
+
|
|
144
|
+
`class x_center y_center width height`
|
|
145
|
+
|
|
146
|
+
normalized to `[0, 1]`.
|
|
147
|
+
|
|
148
|
+
### Retrain
|
|
149
|
+
|
|
150
|
+
Each click creates:
|
|
151
|
+
|
|
152
|
+
- `<retrain_root>/dataset/`
|
|
153
|
+
- `images/train`, `images/val`
|
|
154
|
+
- `labels/train`, `labels/val`
|
|
155
|
+
- `dataset.yaml`
|
|
156
|
+
- Trains YOLO from those corrections
|
|
157
|
+
- Copies best model to:
|
|
158
|
+
- `<retrain_root>/best.pt`
|
|
159
|
+
- Deletes training traces folder after extracting `best.pt`
|
|
160
|
+
|
|
161
|
+
`<retrain_root>` resolution:
|
|
162
|
+
|
|
163
|
+
- If destination field is set, retraining outputs there.
|
|
164
|
+
- Otherwise it defaults to `my_model/retrained_<timestamp>`.
|
|
165
|
+
|
|
166
|
+
So the retrained folder keeps a clean dataset + final model, without run artifacts.
|
|
167
|
+
|
|
168
|
+
Warning: Data labeled will be equally divided between traning and validation (50%).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
simple_napari_cci_annotator/__init__.py,sha256=cu8fkwHo_DMPVZqFYOb-uhVYQduQ5lTnELJg8Q1JNgs,118
|
|
2
|
+
simple_napari_cci_annotator/_widget.py,sha256=dm4pH8Bd5vTOo3Lrp8bvM2tG6I54mgNUiq02l6poNmw,17232
|
|
3
|
+
simple_napari_cci_annotator/_yolo_utils.py,sha256=nBwF7XIc5qRyw2zfBnVPrBdOQHWBBgzVneKL8PNENdk,7861
|
|
4
|
+
simple_napari_cci_annotator/napari.yaml,sha256=RLbL8HjBYwGeXjgGPzIW1G7emMwvEcKtJKjb8HNhLD8,594
|
|
5
|
+
simple_napari_cci_annotator/models/yolov8n.pt,sha256=9Zs9gz4v8y4ZS1u44I0hHcfFvfFEuQ0shBLEfM_IOzY,6549796
|
|
6
|
+
simple_napari_cci_annotator-0.0.2.dist-info/licenses/LICENSE,sha256=ylVLGgTtW9zploUO9-daMtXT3TfO9FQO5AveMUbiNWY,1070
|
|
7
|
+
simple_napari_cci_annotator-0.0.2.dist-info/METADATA,sha256=Y_NxfcFC1-R2cCOEikNpogVaaktKKgk1x2ShasqYr70,6017
|
|
8
|
+
simple_napari_cci_annotator-0.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
simple_napari_cci_annotator-0.0.2.dist-info/entry_points.txt,sha256=1dZ2XtQ1WqVc4SUQxPWsrtdmmjNWHFYoUAjXYmzEgyI,88
|
|
10
|
+
simple_napari_cci_annotator-0.0.2.dist-info/top_level.txt,sha256=R7vG9R7q36NzsIlXNIxjnuKpdVurRh6Rfq-CK2Knqog,28
|
|
11
|
+
simple_napari_cci_annotator-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LECLERC Simon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simple_napari_cci_annotator
|