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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magiclabel
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: AI-assisted image annotation CLI for computer vision datasets
5
5
  Author: Rohan Rustagi
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """magiclabel – Open‑source CLI tool for AI‑assisted image annotation."""
2
- __version__ = "0.2.0"
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" # closed-set: 80 COCO classes
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 (e.g. yolov8n.pt) detects its
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 (e.g. ["missile", "launcher"]).
30
+ named objects with no training.
25
31
 
26
- Writes one YOLO `.txt` per image plus a `classes.txt` (the class list, in id
27
- order) into `output`. Returns a summary dict {classes, images, boxes}.
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
- class_names = [c.strip() for c in prompt_classes if c.strip()]
39
- if not class_names:
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(class_names)
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
- class_names = [detector.names[i] for i in sorted(detector.names)]
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
- _write_classes_file(output, class_names)
64
- return {"classes": class_names, "images": len(images), "boxes": total_boxes}
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
- classes_file = labels_dir / "classes.txt"
9
- if not classes_file.exists():
10
- raise FileNotFoundError(
11
- f"{classes_file} not found. Run `magiclabel detect` or `review` first — "
12
- "they create the class list (classes.txt) this command trains on."
13
- )
14
- names = [ln.strip() for ln in classes_file.read_text().splitlines() if ln.strip()]
15
- if not names:
16
- raise ValueError("classes.txt is empty — there are no classes to train on.")
17
- return names
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 _build_dataset(images_dir, labels_dir, names, train_set, val_set, work_dir):
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
- shutil.copy(labels_dir / f"{img.stem}.txt",
43
- work_dir / "labels" / split / f"{img.stem}.txt")
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
- Reads classes.txt + YOLO label files, builds a train/val split, fine-tunes `base`,
64
- and copies the best weights to `name` (e.g. rohan.pt). Returns a summary dict.
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
- train_set, val_set = _split(labeled, val_split)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magiclabel
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: AI-assisted image annotation CLI for computer vision datasets
5
5
  Author: Rohan Rustagi
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "magiclabel"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "AI-assisted image annotation CLI for computer vision datasets"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes
File without changes
File without changes