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.
@@ -0,0 +1,7 @@
1
+ from ._widget import SimpleCciAnnotatorQWidget
2
+
3
+ __version__ = "0.0.2"
4
+
5
+ __all__ = [
6
+ "SimpleCciAnnotatorQWidget",
7
+ ]
@@ -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)
@@ -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
+ [![License MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/CCI-GU-Sweden/simple-napari-cci-annotator/blob/main/LICENSE)
60
+ [![Python 3.10–3.12](https://img.shields.io/badge/python-3.10%20|%203.11%20|%203.12-blue)](https://python.org)
61
+ [![tests](https://github.com/CCI-GU-Sweden/simple-napari-cci-annotator/workflows/tests/badge.svg)](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
+ ![UI](assets/Plugin_UI.png)
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
+ ![Loading a model](assets/Loading_model.png)
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
+ ![Predicting label](assets/Predicting_label.png)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [napari.manifest]
2
+ simple-napari-cci-annotator = simple_napari_cci_annotator:napari.yaml
@@ -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