magiclabel 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {magiclabel-0.2.0 → magiclabel-0.2.1}/PKG-INFO +1 -1
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/__init__.py +1 -1
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/annotator.py +32 -18
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/trainer.py +54 -19
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel.egg-info/PKG-INFO +1 -1
- {magiclabel-0.2.0 → magiclabel-0.2.1}/pyproject.toml +1 -1
- {magiclabel-0.2.0 → magiclabel-0.2.1}/LICENSE +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/README.md +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/cli.py +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/convertor.py +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/review_app.py +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel/utils.py +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel.egg-info/SOURCES.txt +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel.egg-info/dependency_links.txt +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel.egg-info/entry_points.txt +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel.egg-info/requires.txt +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/magiclabel.egg-info/top_level.txt +0 -0
- {magiclabel-0.2.0 → magiclabel-0.2.1}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""magiclabel – Open‑source CLI tool for AI‑assisted image annotation."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.1"
|
|
@@ -4,10 +4,17 @@ from typing import List, Optional
|
|
|
4
4
|
|
|
5
5
|
from rich.progress import Progress
|
|
6
6
|
|
|
7
|
-
DEFAULT_CLOSED_MODEL = "yolov8n.pt"
|
|
7
|
+
DEFAULT_CLOSED_MODEL = "yolov8n.pt" # closed-set: 80 COCO classes
|
|
8
8
|
DEFAULT_OPEN_VOCAB_MODEL = "yolov8s-world.pt" # open-vocabulary: detect anything from text
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _load_classes(output: Path):
|
|
12
|
+
f = output / "classes.txt"
|
|
13
|
+
if not f.exists():
|
|
14
|
+
return []
|
|
15
|
+
return [ln.strip() for ln in f.read_text().splitlines() if ln.strip()]
|
|
16
|
+
|
|
17
|
+
|
|
11
18
|
def detect_images(
|
|
12
19
|
source: Path,
|
|
13
20
|
output: Path,
|
|
@@ -18,13 +25,14 @@ def detect_images(
|
|
|
18
25
|
"""Auto-annotate every image in `source` with bounding boxes.
|
|
19
26
|
|
|
20
27
|
Two modes:
|
|
21
|
-
* closed-set (default): a normal YOLO model
|
|
22
|
-
built-in classes (the 80 COCO objects).
|
|
28
|
+
* closed-set (default): a normal YOLO model detects its built-in classes.
|
|
23
29
|
* open-vocabulary: when `prompt_classes` is given, YOLO-World detects those
|
|
24
|
-
named objects with no training
|
|
30
|
+
named objects with no training.
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
Class handling (consistent + compact): labels are written by *class name*. The
|
|
33
|
+
class list lives in `output/classes.txt` and is MERGED, never clobbered — only
|
|
34
|
+
classes that are actually detected get an id, and re-running detect (or adding
|
|
35
|
+
custom classes in `review`) keeps ids and names in sync. Returns a summary dict.
|
|
28
36
|
"""
|
|
29
37
|
output = Path(output)
|
|
30
38
|
output.mkdir(parents=True, exist_ok=True)
|
|
@@ -35,14 +43,25 @@ def detect_images(
|
|
|
35
43
|
if prompt_classes:
|
|
36
44
|
from ultralytics import YOLOWorld
|
|
37
45
|
detector = YOLOWorld(model_name or DEFAULT_OPEN_VOCAB_MODEL)
|
|
38
|
-
|
|
39
|
-
if not
|
|
46
|
+
model_names = [c.strip() for c in prompt_classes if c.strip()]
|
|
47
|
+
if not model_names:
|
|
40
48
|
raise ValueError("No class names given in --prompt")
|
|
41
|
-
detector.set_classes(
|
|
49
|
+
detector.set_classes(model_names)
|
|
42
50
|
else:
|
|
43
51
|
from ultralytics import YOLO
|
|
44
52
|
detector = YOLO(model_name or DEFAULT_CLOSED_MODEL)
|
|
45
|
-
|
|
53
|
+
model_names = [detector.names[i] for i in sorted(detector.names)]
|
|
54
|
+
|
|
55
|
+
# name-consistent registry, seeded from any existing classes.txt (merge, don't clobber)
|
|
56
|
+
registry = _load_classes(output)
|
|
57
|
+
name_to_id = {n: i for i, n in enumerate(registry)}
|
|
58
|
+
|
|
59
|
+
def reg_id(model_idx):
|
|
60
|
+
name = model_names[model_idx] if 0 <= model_idx < len(model_names) else str(model_idx)
|
|
61
|
+
if name not in name_to_id:
|
|
62
|
+
name_to_id[name] = len(registry)
|
|
63
|
+
registry.append(name)
|
|
64
|
+
return name_to_id[name]
|
|
46
65
|
|
|
47
66
|
total_boxes = 0
|
|
48
67
|
with Progress() as progress:
|
|
@@ -51,7 +70,7 @@ def detect_images(
|
|
|
51
70
|
results = detector.predict(img_path, conf=conf, verbose=False)
|
|
52
71
|
boxes = results[0].boxes
|
|
53
72
|
if boxes is not None and len(boxes) > 0:
|
|
54
|
-
cls_ids = boxes.cls.int().tolist()
|
|
73
|
+
cls_ids = [reg_id(mi) for mi in boxes.cls.int().tolist()]
|
|
55
74
|
xyxy = boxes.xyxy.tolist()
|
|
56
75
|
abs_boxes = [[x1, y1, x2 - x1, y2 - y1] for x1, y1, x2, y2 in xyxy]
|
|
57
76
|
img = cv2.imread(str(img_path))
|
|
@@ -60,8 +79,8 @@ def detect_images(
|
|
|
60
79
|
total_boxes += len(abs_boxes)
|
|
61
80
|
progress.advance(task)
|
|
62
81
|
|
|
63
|
-
|
|
64
|
-
return {"classes":
|
|
82
|
+
(output / "classes.txt").write_text("\n".join(registry) + "\n")
|
|
83
|
+
return {"classes": registry, "images": len(images), "boxes": total_boxes}
|
|
65
84
|
|
|
66
85
|
|
|
67
86
|
def _save_yolo_boxes(txt_path, boxes, classes, img_w, img_h):
|
|
@@ -75,11 +94,6 @@ def _save_yolo_boxes(txt_path, boxes, classes, img_w, img_h):
|
|
|
75
94
|
f.write(f"{cls} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f}\n")
|
|
76
95
|
|
|
77
96
|
|
|
78
|
-
def _write_classes_file(output, class_names):
|
|
79
|
-
"""Write the class list (one name per line, id == line index) for review/training."""
|
|
80
|
-
(Path(output) / "classes.txt").write_text("\n".join(class_names) + "\n")
|
|
81
|
-
|
|
82
|
-
|
|
83
97
|
def _get_image_files(source):
|
|
84
98
|
from magiclabel.utils import get_image_files
|
|
85
99
|
return get_image_files(source)
|
|
@@ -5,16 +5,24 @@ from magiclabel.utils import get_image_files
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def _read_classes(labels_dir: Path):
|
|
8
|
-
|
|
9
|
-
if not
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
f = labels_dir / "classes.txt"
|
|
9
|
+
if not f.exists():
|
|
10
|
+
return []
|
|
11
|
+
return [ln.strip() for ln in f.read_text().splitlines() if ln.strip()]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _used_class_ids(labels_dir: Path, labeled):
|
|
15
|
+
"""All class ids that actually appear across the label files."""
|
|
16
|
+
used = set()
|
|
17
|
+
for img in labeled:
|
|
18
|
+
for ln in (labels_dir / f"{img.stem}.txt").read_text().splitlines():
|
|
19
|
+
parts = ln.split()
|
|
20
|
+
if len(parts) >= 5:
|
|
21
|
+
try:
|
|
22
|
+
used.add(int(float(parts[0])))
|
|
23
|
+
except ValueError:
|
|
24
|
+
pass
|
|
25
|
+
return sorted(used)
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
def _split(labeled, val_split):
|
|
@@ -31,7 +39,24 @@ def _split(labeled, val_split):
|
|
|
31
39
|
return train_set, val_set
|
|
32
40
|
|
|
33
41
|
|
|
34
|
-
def
|
|
42
|
+
def _write_remapped_label(src_txt, dst_txt, remap):
|
|
43
|
+
"""Copy a label file, rewriting each box's class id through `remap`."""
|
|
44
|
+
out = []
|
|
45
|
+
for ln in src_txt.read_text().splitlines():
|
|
46
|
+
parts = ln.split()
|
|
47
|
+
if len(parts) < 5:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
old = int(float(parts[0]))
|
|
51
|
+
except ValueError:
|
|
52
|
+
continue
|
|
53
|
+
if old not in remap:
|
|
54
|
+
continue
|
|
55
|
+
out.append(" ".join([str(remap[old])] + parts[1:]))
|
|
56
|
+
dst_txt.write_text("\n".join(out) + ("\n" if out else ""))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_dataset(images_dir, labels_dir, names, remap, train_set, val_set, work_dir):
|
|
35
60
|
if work_dir.exists():
|
|
36
61
|
shutil.rmtree(work_dir)
|
|
37
62
|
for split, items in (("train", train_set), ("val", val_set)):
|
|
@@ -39,10 +64,12 @@ def _build_dataset(images_dir, labels_dir, names, train_set, val_set, work_dir):
|
|
|
39
64
|
(work_dir / "labels" / split).mkdir(parents=True, exist_ok=True)
|
|
40
65
|
for img in items:
|
|
41
66
|
shutil.copy(img, work_dir / "images" / split / img.name)
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
_write_remapped_label(
|
|
68
|
+
labels_dir / f"{img.stem}.txt",
|
|
69
|
+
work_dir / "labels" / split / f"{img.stem}.txt",
|
|
70
|
+
remap,
|
|
71
|
+
)
|
|
44
72
|
|
|
45
|
-
# data.yaml — quote names so spaces/punctuation are safe
|
|
46
73
|
lines = [f"path: {work_dir.resolve()}", "train: images/train", "val: images/val", "names:"]
|
|
47
74
|
lines += [f' {i}: "{n}"' for i, n in enumerate(names)]
|
|
48
75
|
(work_dir / "data.yaml").write_text("\n".join(lines) + "\n")
|
|
@@ -60,12 +87,13 @@ def train_model(
|
|
|
60
87
|
) -> dict:
|
|
61
88
|
"""Train a custom YOLO detector on a labeled dataset and save it as a named .pt.
|
|
62
89
|
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
The dataset is COMPACTED to only the classes that actually appear in the labels:
|
|
91
|
+
used class ids are remapped to a contiguous 0..K-1, named from classes.txt (with a
|
|
92
|
+
`class_<id>` fallback for any id missing a name). This keeps training robust even if
|
|
93
|
+
labels contain gaps or ids beyond classes.txt — no class-count crashes, no bloat.
|
|
65
94
|
"""
|
|
66
95
|
images_dir = Path(images_dir)
|
|
67
96
|
labels_dir = Path(labels_dir)
|
|
68
|
-
names = _read_classes(labels_dir)
|
|
69
97
|
|
|
70
98
|
labeled = [p for p in get_image_files(images_dir) if (labels_dir / f"{p.stem}.txt").exists()]
|
|
71
99
|
if len(labeled) < 2:
|
|
@@ -74,14 +102,21 @@ def train_model(
|
|
|
74
102
|
"Label more images in `review` first."
|
|
75
103
|
)
|
|
76
104
|
|
|
77
|
-
|
|
105
|
+
used = _used_class_ids(labels_dir, labeled)
|
|
106
|
+
if not used:
|
|
107
|
+
raise ValueError("No labeled boxes found — every label file is empty.")
|
|
108
|
+
|
|
109
|
+
all_names = _read_classes(labels_dir)
|
|
110
|
+
remap = {old: new for new, old in enumerate(used)}
|
|
111
|
+
names = [all_names[old] if 0 <= old < len(all_names) else f"class_{old}" for old in used]
|
|
78
112
|
|
|
79
113
|
out_path = Path(name)
|
|
80
114
|
if out_path.suffix != ".pt":
|
|
81
115
|
out_path = out_path.with_suffix(".pt")
|
|
82
116
|
|
|
117
|
+
train_set, val_set = _split(labeled, val_split)
|
|
83
118
|
work_dir = Path(f"{out_path.stem}_dataset").resolve()
|
|
84
|
-
data_yaml = _build_dataset(images_dir, labels_dir, names, train_set, val_set, work_dir)
|
|
119
|
+
data_yaml = _build_dataset(images_dir, labels_dir, names, remap, train_set, val_set, work_dir)
|
|
85
120
|
|
|
86
121
|
from ultralytics import YOLO
|
|
87
122
|
model = YOLO(base)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|