magiclabel 0.1.0__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.
- magiclabel/__init__.py +2 -0
- magiclabel/annotator.py +85 -0
- magiclabel/cli.py +114 -0
- magiclabel/convertor.py +111 -0
- magiclabel/review_app.py +212 -0
- magiclabel/trainer.py +110 -0
- magiclabel/utils.py +12 -0
- magiclabel-0.1.0.dist-info/METADATA +304 -0
- magiclabel-0.1.0.dist-info/RECORD +13 -0
- magiclabel-0.1.0.dist-info/WHEEL +5 -0
- magiclabel-0.1.0.dist-info/entry_points.txt +2 -0
- magiclabel-0.1.0.dist-info/licenses/LICENSE +21 -0
- magiclabel-0.1.0.dist-info/top_level.txt +1 -0
magiclabel/__init__.py
ADDED
magiclabel/annotator.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from rich.progress import Progress
|
|
6
|
+
|
|
7
|
+
DEFAULT_CLOSED_MODEL = "yolov8n.pt" # closed-set: 80 COCO classes
|
|
8
|
+
DEFAULT_OPEN_VOCAB_MODEL = "yolov8s-world.pt" # open-vocabulary: detect anything from text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_images(
|
|
12
|
+
source: Path,
|
|
13
|
+
output: Path,
|
|
14
|
+
conf: float,
|
|
15
|
+
prompt_classes: Optional[List[str]] = None,
|
|
16
|
+
model_name: Optional[str] = None,
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""Auto-annotate every image in `source` with bounding boxes.
|
|
19
|
+
|
|
20
|
+
Two modes:
|
|
21
|
+
* closed-set (default): a normal YOLO model (e.g. yolov8n.pt) detects its
|
|
22
|
+
built-in classes (the 80 COCO objects).
|
|
23
|
+
* open-vocabulary: when `prompt_classes` is given, YOLO-World detects those
|
|
24
|
+
named objects with no training (e.g. ["missile", "launcher"]).
|
|
25
|
+
|
|
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}.
|
|
28
|
+
"""
|
|
29
|
+
output = Path(output)
|
|
30
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
images = _get_image_files(source)
|
|
32
|
+
if not images:
|
|
33
|
+
raise FileNotFoundError(f"No images found in {source}")
|
|
34
|
+
|
|
35
|
+
if prompt_classes:
|
|
36
|
+
from ultralytics import YOLOWorld
|
|
37
|
+
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:
|
|
40
|
+
raise ValueError("No class names given in --prompt")
|
|
41
|
+
detector.set_classes(class_names)
|
|
42
|
+
else:
|
|
43
|
+
from ultralytics import YOLO
|
|
44
|
+
detector = YOLO(model_name or DEFAULT_CLOSED_MODEL)
|
|
45
|
+
class_names = [detector.names[i] for i in sorted(detector.names)]
|
|
46
|
+
|
|
47
|
+
total_boxes = 0
|
|
48
|
+
with Progress() as progress:
|
|
49
|
+
task = progress.add_task("Detecting...", total=len(images))
|
|
50
|
+
for img_path in images:
|
|
51
|
+
results = detector.predict(img_path, conf=conf, verbose=False)
|
|
52
|
+
boxes = results[0].boxes
|
|
53
|
+
if boxes is not None and len(boxes) > 0:
|
|
54
|
+
cls_ids = boxes.cls.int().tolist()
|
|
55
|
+
xyxy = boxes.xyxy.tolist()
|
|
56
|
+
abs_boxes = [[x1, y1, x2 - x1, y2 - y1] for x1, y1, x2, y2 in xyxy]
|
|
57
|
+
img = cv2.imread(str(img_path))
|
|
58
|
+
h, w = img.shape[:2]
|
|
59
|
+
_save_yolo_boxes(output / f"{img_path.stem}.txt", abs_boxes, cls_ids, w, h)
|
|
60
|
+
total_boxes += len(abs_boxes)
|
|
61
|
+
progress.advance(task)
|
|
62
|
+
|
|
63
|
+
_write_classes_file(output, class_names)
|
|
64
|
+
return {"classes": class_names, "images": len(images), "boxes": total_boxes}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _save_yolo_boxes(txt_path, boxes, classes, img_w, img_h):
|
|
68
|
+
with open(txt_path, "w") as f:
|
|
69
|
+
for box, cls in zip(boxes, classes):
|
|
70
|
+
x, y, w_box, h_box = box
|
|
71
|
+
xc = (x + w_box / 2) / img_w
|
|
72
|
+
yc = (y + h_box / 2) / img_h
|
|
73
|
+
nw = w_box / img_w
|
|
74
|
+
nh = h_box / img_h
|
|
75
|
+
f.write(f"{cls} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f}\n")
|
|
76
|
+
|
|
77
|
+
|
|
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
|
+
def _get_image_files(source):
|
|
84
|
+
from magiclabel.utils import get_image_files
|
|
85
|
+
return get_image_files(source)
|
magiclabel/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from magiclabel.annotator import detect_images
|
|
6
|
+
from magiclabel.convertor import yolo_to_coco
|
|
7
|
+
from magiclabel.review_app import launch_review
|
|
8
|
+
from magiclabel.trainer import train_model
|
|
9
|
+
from magiclabel.utils import suppress_ultralytics_output
|
|
10
|
+
|
|
11
|
+
suppress_ultralytics_output()
|
|
12
|
+
console = Console()
|
|
13
|
+
app = typer.Typer(help="AI‑assisted image annotation CLI")
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def detect(
|
|
17
|
+
source: Path = typer.Argument(..., help="Directory containing images"),
|
|
18
|
+
prompt: Optional[str] = typer.Option(
|
|
19
|
+
None, "--prompt",
|
|
20
|
+
help="Comma-separated object names for open-vocabulary detection, e.g. "
|
|
21
|
+
"'missile,launcher,radar dish'. No training needed — detects anything you name.",
|
|
22
|
+
),
|
|
23
|
+
model: Optional[str] = typer.Option(
|
|
24
|
+
None, help="Model file. Default: yolov8n.pt (closed-set), or yolov8s-world.pt with --prompt.",
|
|
25
|
+
),
|
|
26
|
+
output: Path = typer.Option("./labels_detect", help="Output directory for labels"),
|
|
27
|
+
conf: float = typer.Option(0.25, help="Confidence threshold"),
|
|
28
|
+
):
|
|
29
|
+
"""Auto‑annotate with bounding boxes (closed-set YOLO, or open-vocabulary with --prompt)."""
|
|
30
|
+
prompt_classes = [c.strip() for c in prompt.split(",")] if prompt else None
|
|
31
|
+
try:
|
|
32
|
+
summary = detect_images(source, output, conf, prompt_classes, model)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
console.print(f"[red]Error:[/] {e}")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
mode = "open-vocabulary" if prompt_classes else "closed-set"
|
|
37
|
+
console.print(
|
|
38
|
+
f"[green]✓[/] {summary['boxes']} boxes over {summary['images']} images "
|
|
39
|
+
f"saved to {output} ({mode})"
|
|
40
|
+
)
|
|
41
|
+
if prompt_classes:
|
|
42
|
+
console.print(f" classes: {', '.join(summary['classes'])}")
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def yolo2coco(
|
|
46
|
+
images_dir: Path = typer.Argument(..., help="Image directory"),
|
|
47
|
+
labels_dir: Path = typer.Argument(..., help="YOLO labels directory (detection format)"),
|
|
48
|
+
output_json: Path = typer.Argument(..., help="Output COCO JSON"),
|
|
49
|
+
class_names: List[str] = typer.Option(..., "--class", help="Class names in order (0..N-1)"),
|
|
50
|
+
):
|
|
51
|
+
"""Convert YOLO detection labels to COCO JSON."""
|
|
52
|
+
try:
|
|
53
|
+
summary = yolo_to_coco(images_dir, labels_dir, output_json, list(class_names))
|
|
54
|
+
except Exception as e:
|
|
55
|
+
console.print(f"[red]Error:[/] {e}")
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
if summary["auto_named"]:
|
|
58
|
+
ids = ", ".join(str(i) for i in summary["auto_named"])
|
|
59
|
+
console.print(
|
|
60
|
+
f"[yellow]![/] No --class name given for class id(s) {ids}; "
|
|
61
|
+
f"named them 'class_<id>'. Pass a --class name for every id to fix this."
|
|
62
|
+
)
|
|
63
|
+
console.print(
|
|
64
|
+
f"[green]✓[/] COCO JSON saved to {output_json} "
|
|
65
|
+
f"({summary['images']} images, {summary['annotations']} annotations, "
|
|
66
|
+
f"{summary['categories']} categories)"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def review(
|
|
71
|
+
images_dir: Path = typer.Argument(..., help="Image directory"),
|
|
72
|
+
labels_dir: Path = typer.Argument(..., help="Directory with YOLO label .txt files"),
|
|
73
|
+
model: str = typer.Option("yolov8n.pt", help="Model whose class names label the boxes"),
|
|
74
|
+
):
|
|
75
|
+
"""Launch a Gradio web interface to review and edit annotations."""
|
|
76
|
+
try:
|
|
77
|
+
launch_review(str(images_dir), str(labels_dir), model)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
console.print(f"[red]Error:[/] {e}")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def train(
|
|
84
|
+
images_dir: Path = typer.Argument(..., help="Image directory"),
|
|
85
|
+
labels_dir: Path = typer.Argument(..., help="Directory with YOLO labels + classes.txt"),
|
|
86
|
+
name: str = typer.Option("custom.pt", help="Name for your trained model, e.g. rohan.pt"),
|
|
87
|
+
base: str = typer.Option("yolov8n.pt", help="Base model to fine-tune"),
|
|
88
|
+
epochs: int = typer.Option(50, help="Training epochs"),
|
|
89
|
+
imgsz: int = typer.Option(640, help="Training image size"),
|
|
90
|
+
val_split: float = typer.Option(0.2, help="Fraction of images held out for validation"),
|
|
91
|
+
):
|
|
92
|
+
"""Train a custom YOLO model on your labeled dataset → a named .pt you can reuse."""
|
|
93
|
+
try:
|
|
94
|
+
summary = train_model(str(images_dir), str(labels_dir), name, base, epochs, imgsz, val_split)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
console.print(f"[red]Error:[/] {e}")
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
console.print(
|
|
99
|
+
f"[green]✓[/] Trained [bold]{summary['model']}[/] on "
|
|
100
|
+
f"{summary['train']} train / {summary['val']} val images ({summary['epochs']} epochs)"
|
|
101
|
+
)
|
|
102
|
+
console.print(f" classes: {', '.join(summary['classes'])}")
|
|
103
|
+
console.print(f" Use it: magiclabel detect ./new_images --model {summary['model']}")
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def export_classes(model: str = typer.Argument("yolov8n.pt")):
|
|
107
|
+
"""Print class names used by a YOLO model."""
|
|
108
|
+
from ultralytics import YOLO
|
|
109
|
+
detector = YOLO(model)
|
|
110
|
+
for k, v in detector.names.items():
|
|
111
|
+
console.print(f"{k}: {v}")
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
app()
|
magiclabel/convertor.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
|
|
6
|
+
from magiclabel.utils import get_image_files
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def yolo_to_coco(
|
|
10
|
+
images_dir: Path,
|
|
11
|
+
labels_dir: Path,
|
|
12
|
+
output_json: Path,
|
|
13
|
+
class_names: list,
|
|
14
|
+
):
|
|
15
|
+
"""Convert a YOLO detection dataset (one .txt per image, normalized
|
|
16
|
+
`cls xc yc w h` lines) into a single COCO-format JSON file.
|
|
17
|
+
|
|
18
|
+
COCO category ids are 1-indexed (category_id = yolo_class_id + 1), matching
|
|
19
|
+
the COCO convention; image and annotation ids are 1-indexed as well. Class
|
|
20
|
+
ids that have no provided name are auto-named `class_<id>` so conversion
|
|
21
|
+
always produces valid output.
|
|
22
|
+
|
|
23
|
+
Returns a summary dict: {images, annotations, categories, auto_named}.
|
|
24
|
+
"""
|
|
25
|
+
images_dir = Path(images_dir)
|
|
26
|
+
labels_dir = Path(labels_dir)
|
|
27
|
+
|
|
28
|
+
images = get_image_files(images_dir)
|
|
29
|
+
if not images:
|
|
30
|
+
raise FileNotFoundError(f"No images found in {images_dir}")
|
|
31
|
+
|
|
32
|
+
# First pass: discover every class id that actually appears in the labels.
|
|
33
|
+
present = set()
|
|
34
|
+
for img_path in images:
|
|
35
|
+
label_path = labels_dir / f"{img_path.stem}.txt"
|
|
36
|
+
if not label_path.exists():
|
|
37
|
+
continue
|
|
38
|
+
with open(label_path) as f:
|
|
39
|
+
for line in f:
|
|
40
|
+
parts = line.strip().split()
|
|
41
|
+
if len(parts) >= 5:
|
|
42
|
+
present.add(int(float(parts[0])))
|
|
43
|
+
|
|
44
|
+
def name_for(cls_id):
|
|
45
|
+
if 0 <= cls_id < len(class_names):
|
|
46
|
+
return class_names[cls_id]
|
|
47
|
+
return f"class_{cls_id}"
|
|
48
|
+
|
|
49
|
+
# Categories cover both the names the user supplied and any id seen in labels.
|
|
50
|
+
all_ids = sorted(set(range(len(class_names))) | present)
|
|
51
|
+
categories = [
|
|
52
|
+
{"id": i + 1, "name": name_for(i), "supercategory": "none"}
|
|
53
|
+
for i in all_ids
|
|
54
|
+
]
|
|
55
|
+
auto_named = sorted(i for i in present if i >= len(class_names))
|
|
56
|
+
|
|
57
|
+
coco = {"images": [], "annotations": [], "categories": categories}
|
|
58
|
+
ann_id = 1
|
|
59
|
+
|
|
60
|
+
for img_id, img_path in enumerate(images, start=1):
|
|
61
|
+
img = cv2.imread(str(img_path))
|
|
62
|
+
if img is None:
|
|
63
|
+
raise RuntimeError(f"Could not read image: {img_path}")
|
|
64
|
+
h, w = img.shape[:2]
|
|
65
|
+
|
|
66
|
+
coco["images"].append({
|
|
67
|
+
"id": img_id,
|
|
68
|
+
"file_name": img_path.name,
|
|
69
|
+
"width": w,
|
|
70
|
+
"height": h,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
label_path = labels_dir / f"{img_path.stem}.txt"
|
|
74
|
+
if not label_path.exists():
|
|
75
|
+
continue # image with no objects is still valid
|
|
76
|
+
|
|
77
|
+
with open(label_path) as f:
|
|
78
|
+
for line in f:
|
|
79
|
+
parts = line.strip().split()
|
|
80
|
+
if len(parts) < 5:
|
|
81
|
+
continue
|
|
82
|
+
cls = int(float(parts[0]))
|
|
83
|
+
xc, yc, bw, bh = map(float, parts[1:5])
|
|
84
|
+
|
|
85
|
+
abs_w = bw * w
|
|
86
|
+
abs_h = bh * h
|
|
87
|
+
x = (xc - bw / 2) * w
|
|
88
|
+
y = (yc - bh / 2) * h
|
|
89
|
+
|
|
90
|
+
coco["annotations"].append({
|
|
91
|
+
"id": ann_id,
|
|
92
|
+
"image_id": img_id,
|
|
93
|
+
"category_id": cls + 1,
|
|
94
|
+
"bbox": [round(x, 2), round(y, 2), round(abs_w, 2), round(abs_h, 2)],
|
|
95
|
+
"area": round(abs_w * abs_h, 2),
|
|
96
|
+
"iscrowd": 0,
|
|
97
|
+
"segmentation": [],
|
|
98
|
+
})
|
|
99
|
+
ann_id += 1
|
|
100
|
+
|
|
101
|
+
output_json = Path(output_json)
|
|
102
|
+
output_json.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with open(output_json, "w") as f:
|
|
104
|
+
json.dump(coco, f, indent=2)
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"images": len(coco["images"]),
|
|
108
|
+
"annotations": len(coco["annotations"]),
|
|
109
|
+
"categories": len(categories),
|
|
110
|
+
"auto_named": auto_named,
|
|
111
|
+
}
|
magiclabel/review_app.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import gradio as gr
|
|
5
|
+
from gradio_image_annotation import image_annotator
|
|
6
|
+
|
|
7
|
+
IMAGE_EXTS = (".jpg", ".jpeg", ".png")
|
|
8
|
+
CLASSES_FILE = "classes.txt"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _image_size(path):
|
|
12
|
+
img = cv2.imread(str(path))
|
|
13
|
+
if img is None:
|
|
14
|
+
raise RuntimeError(f"Could not read image: {path}")
|
|
15
|
+
h, w = img.shape[:2]
|
|
16
|
+
return w, h
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _model_class_names(model_name):
|
|
20
|
+
"""Return {class_id: name} from a YOLO model, or {} if it can't be loaded."""
|
|
21
|
+
if not model_name or str(model_name).lower() in ("none", ""):
|
|
22
|
+
return {}
|
|
23
|
+
try:
|
|
24
|
+
from ultralytics import YOLO
|
|
25
|
+
return {int(k): str(v) for k, v in YOLO(model_name).names.items()}
|
|
26
|
+
except Exception:
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_yolo_boxes(txt_path, img_w, img_h, name_of=None):
|
|
31
|
+
"""Read a YOLO .txt into image_annotator box dicts; the box label is the class
|
|
32
|
+
*name* via name_of(id) (defaults to the numeric id as a string)."""
|
|
33
|
+
name_of = name_of or (lambda c: str(c))
|
|
34
|
+
boxes = []
|
|
35
|
+
if not txt_path.exists():
|
|
36
|
+
return boxes
|
|
37
|
+
with open(txt_path) as f:
|
|
38
|
+
for line in f:
|
|
39
|
+
parts = line.strip().split()
|
|
40
|
+
if len(parts) < 5:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
cls = int(float(parts[0]))
|
|
44
|
+
xc, yc, w, h = map(float, parts[1:5])
|
|
45
|
+
except ValueError:
|
|
46
|
+
continue
|
|
47
|
+
xmin = int(round((xc - w / 2) * img_w))
|
|
48
|
+
ymin = int(round((yc - h / 2) * img_h))
|
|
49
|
+
xmax = int(round((xc + w / 2) * img_w))
|
|
50
|
+
ymax = int(round((yc + h / 2) * img_h))
|
|
51
|
+
boxes.append({
|
|
52
|
+
"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax,
|
|
53
|
+
"label": name_of(cls),
|
|
54
|
+
})
|
|
55
|
+
return boxes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_annotation(img_path, labels_dir, name_of=None):
|
|
59
|
+
img_path = Path(img_path)
|
|
60
|
+
w, h = _image_size(img_path)
|
|
61
|
+
labels_path = Path(labels_dir) / f"{img_path.stem}.txt"
|
|
62
|
+
boxes = read_yolo_boxes(labels_path, w, h, name_of)
|
|
63
|
+
return {"image": str(img_path), "boxes": boxes}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clear_boxes(img_path):
|
|
67
|
+
"""Remove every box from the current image (does not write to disk until Save)."""
|
|
68
|
+
return ({"image": str(Path(img_path)), "boxes": []},
|
|
69
|
+
"Cleared all boxes — click Save Changes to write the empty label file.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fallback_id(label):
|
|
73
|
+
try:
|
|
74
|
+
return int(float(label))
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def save_annotation(annotation, img_path, labels_dir, id_of=None):
|
|
80
|
+
"""Write boxes back to the YOLO .txt. id_of(label)->int maps a box's (free-text)
|
|
81
|
+
label to its class id; defaults to parsing the label as a number."""
|
|
82
|
+
id_of = id_of or _fallback_id
|
|
83
|
+
if annotation is None:
|
|
84
|
+
return "Nothing to save."
|
|
85
|
+
img_path = Path(img_path)
|
|
86
|
+
w, h = _image_size(img_path)
|
|
87
|
+
labels_path = Path(labels_dir) / f"{img_path.stem}.txt"
|
|
88
|
+
boxes = annotation.get("boxes", []) or []
|
|
89
|
+
written = 0
|
|
90
|
+
with open(labels_path, "w") as f:
|
|
91
|
+
for b in boxes:
|
|
92
|
+
# box corners may come in any order; normalize and skip degenerate boxes
|
|
93
|
+
x1, x2 = sorted((b["xmin"], b["xmax"]))
|
|
94
|
+
y1, y2 = sorted((b["ymin"], b["ymax"]))
|
|
95
|
+
bw, bh = x2 - x1, y2 - y1
|
|
96
|
+
if bw <= 0 or bh <= 0:
|
|
97
|
+
continue
|
|
98
|
+
cls = id_of(b.get("label", ""))
|
|
99
|
+
xc = (x1 + bw / 2) / w
|
|
100
|
+
yc = (y1 + bh / 2) / h
|
|
101
|
+
f.write(f"{cls} {xc:.6f} {yc:.6f} {bw / w:.6f} {bh / h:.6f}\n")
|
|
102
|
+
written += 1
|
|
103
|
+
return f"Saved {written} box(es) to {labels_path.name}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_classes_file(labels_dir):
|
|
107
|
+
f = Path(labels_dir) / CLASSES_FILE
|
|
108
|
+
if not f.exists():
|
|
109
|
+
return None
|
|
110
|
+
names = [ln.strip() for ln in f.read_text().splitlines()]
|
|
111
|
+
while names and names[-1] == "":
|
|
112
|
+
names.pop()
|
|
113
|
+
return names
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ClassRegistry:
|
|
117
|
+
"""Maps class names <-> ids and persists the list to labels_dir/classes.txt.
|
|
118
|
+
|
|
119
|
+
Seeded from an existing classes.txt if present, else from the model's class names.
|
|
120
|
+
New names typed in the UI are appended automatically and written back to disk, so
|
|
121
|
+
classes.txt always reflects the dataset's class list (ready for training).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, labels_dir, model_name):
|
|
125
|
+
self.labels_dir = Path(labels_dir)
|
|
126
|
+
registry = _load_classes_file(labels_dir)
|
|
127
|
+
if registry is None:
|
|
128
|
+
seeded = _model_class_names(model_name)
|
|
129
|
+
registry = ([seeded.get(i, str(i)) for i in range(max(seeded) + 1)]
|
|
130
|
+
if seeded else [])
|
|
131
|
+
self.names = registry
|
|
132
|
+
self.name_to_id = {n: i for i, n in enumerate(self.names)}
|
|
133
|
+
self._persist()
|
|
134
|
+
|
|
135
|
+
def name_of(self, cid):
|
|
136
|
+
return self.names[cid] if 0 <= cid < len(self.names) else str(cid)
|
|
137
|
+
|
|
138
|
+
def id_of(self, label):
|
|
139
|
+
name = str(label).strip() or "object"
|
|
140
|
+
if name not in self.name_to_id:
|
|
141
|
+
self.name_to_id[name] = len(self.names)
|
|
142
|
+
self.names.append(name)
|
|
143
|
+
self._persist()
|
|
144
|
+
return self.name_to_id[name]
|
|
145
|
+
|
|
146
|
+
def _persist(self):
|
|
147
|
+
self.labels_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
(self.labels_dir / CLASSES_FILE).write_text("\n".join(self.names) + "\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def launch_review(images_dir: str, labels_dir: str, model_name: str = "yolov8n.pt"):
|
|
152
|
+
images = sorted([str(p) for p in Path(images_dir).glob("*") if p.suffix.lower() in IMAGE_EXTS])
|
|
153
|
+
if not images:
|
|
154
|
+
raise RuntimeError("No images found")
|
|
155
|
+
|
|
156
|
+
labels_dir = str(labels_dir)
|
|
157
|
+
registry = ClassRegistry(labels_dir, model_name)
|
|
158
|
+
|
|
159
|
+
# Open on the first image that actually has boxes, so the view never looks empty
|
|
160
|
+
# when labels exist elsewhere in the folder (falls back to the first image).
|
|
161
|
+
def _has_boxes(img):
|
|
162
|
+
txt = Path(labels_dir) / f"{Path(img).stem}.txt"
|
|
163
|
+
return txt.exists() and any(ln.strip() for ln in txt.read_text().splitlines())
|
|
164
|
+
|
|
165
|
+
start_image = next((im for im in images if _has_boxes(im)), images[0])
|
|
166
|
+
|
|
167
|
+
# labels_dir / registry are constant for the session — bind via closures rather than
|
|
168
|
+
# gr.State (a State value is not reliably delivered to handlers over the API).
|
|
169
|
+
def _load(img_path):
|
|
170
|
+
return load_annotation(img_path, labels_dir, registry.name_of)
|
|
171
|
+
|
|
172
|
+
def _clear(img_path):
|
|
173
|
+
return clear_boxes(img_path)
|
|
174
|
+
|
|
175
|
+
def _save(annotation, img_path):
|
|
176
|
+
msg = save_annotation(annotation, img_path, labels_dir, registry.id_of)
|
|
177
|
+
return load_annotation(img_path, labels_dir, registry.name_of), msg
|
|
178
|
+
|
|
179
|
+
with gr.Blocks(title="MagicLabel Review") as demo:
|
|
180
|
+
gr.Markdown("## Review & Fix Annotations")
|
|
181
|
+
gr.Markdown(
|
|
182
|
+
"**Drag on the image to draw a new box** for objects the model missed. "
|
|
183
|
+
"Click a box to move or resize it via its handles, or delete it with the box's remove "
|
|
184
|
+
"button.\n\n"
|
|
185
|
+
"**To set a box's class:** double‑click the box to open its editor and **type any class "
|
|
186
|
+
"name you want** (e.g. `missile`, `crack`, `logo`) in the label field, then click the "
|
|
187
|
+
"apply/✓ button to commit it. New names are added to your class list automatically and "
|
|
188
|
+
f"saved to `{CLASSES_FILE}` in the labels folder — that file is the class list you'll "
|
|
189
|
+
"train on. After **Save Changes**, the view reloads from the saved file so you can "
|
|
190
|
+
"confirm exactly what was written."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
image_dropdown = gr.Dropdown(choices=images, label="Select Image", value=start_image)
|
|
194
|
+
annotator = image_annotator(
|
|
195
|
+
value=_load(start_image),
|
|
196
|
+
label="Annotations — drag to draw, double‑click a box to type a class name",
|
|
197
|
+
box_thickness=2,
|
|
198
|
+
image_type="filepath",
|
|
199
|
+
show_remove_button=True,
|
|
200
|
+
enable_keyboard_shortcuts=True,
|
|
201
|
+
disable_edit_boxes=False,
|
|
202
|
+
)
|
|
203
|
+
with gr.Row():
|
|
204
|
+
clear_btn = gr.Button("Clear All Boxes")
|
|
205
|
+
save_btn = gr.Button("Save Changes", variant="primary")
|
|
206
|
+
status = gr.Textbox(label="Status", interactive=False)
|
|
207
|
+
|
|
208
|
+
image_dropdown.change(fn=_load, inputs=[image_dropdown], outputs=annotator)
|
|
209
|
+
clear_btn.click(fn=_clear, inputs=[image_dropdown], outputs=[annotator, status])
|
|
210
|
+
save_btn.click(fn=_save, inputs=[annotator, image_dropdown], outputs=[annotator, status])
|
|
211
|
+
|
|
212
|
+
demo.launch(server_name="127.0.0.1", server_port=7860, share=False)
|
magiclabel/trainer.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from magiclabel.utils import get_image_files
|
|
5
|
+
|
|
6
|
+
|
|
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
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _split(labeled, val_split):
|
|
21
|
+
"""Deterministic train/val split: every Nth labeled image goes to validation."""
|
|
22
|
+
if val_split <= 0:
|
|
23
|
+
return labeled, labeled[:1]
|
|
24
|
+
step = max(2, round(1 / val_split))
|
|
25
|
+
train_set = [p for i, p in enumerate(labeled) if i % step != 0]
|
|
26
|
+
val_set = [p for i, p in enumerate(labeled) if i % step == 0]
|
|
27
|
+
if not val_set:
|
|
28
|
+
val_set.append(train_set.pop())
|
|
29
|
+
if not train_set:
|
|
30
|
+
train_set.append(val_set[0])
|
|
31
|
+
return train_set, val_set
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_dataset(images_dir, labels_dir, names, train_set, val_set, work_dir):
|
|
35
|
+
if work_dir.exists():
|
|
36
|
+
shutil.rmtree(work_dir)
|
|
37
|
+
for split, items in (("train", train_set), ("val", val_set)):
|
|
38
|
+
(work_dir / "images" / split).mkdir(parents=True, exist_ok=True)
|
|
39
|
+
(work_dir / "labels" / split).mkdir(parents=True, exist_ok=True)
|
|
40
|
+
for img in items:
|
|
41
|
+
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")
|
|
44
|
+
|
|
45
|
+
# data.yaml — quote names so spaces/punctuation are safe
|
|
46
|
+
lines = [f"path: {work_dir.resolve()}", "train: images/train", "val: images/val", "names:"]
|
|
47
|
+
lines += [f' {i}: "{n}"' for i, n in enumerate(names)]
|
|
48
|
+
(work_dir / "data.yaml").write_text("\n".join(lines) + "\n")
|
|
49
|
+
return work_dir / "data.yaml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def train_model(
|
|
53
|
+
images_dir: str,
|
|
54
|
+
labels_dir: str,
|
|
55
|
+
name: str,
|
|
56
|
+
base: str = "yolov8n.pt",
|
|
57
|
+
epochs: int = 50,
|
|
58
|
+
imgsz: int = 640,
|
|
59
|
+
val_split: float = 0.2,
|
|
60
|
+
) -> dict:
|
|
61
|
+
"""Train a custom YOLO detector on a labeled dataset and save it as a named .pt.
|
|
62
|
+
|
|
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.
|
|
65
|
+
"""
|
|
66
|
+
images_dir = Path(images_dir)
|
|
67
|
+
labels_dir = Path(labels_dir)
|
|
68
|
+
names = _read_classes(labels_dir)
|
|
69
|
+
|
|
70
|
+
labeled = [p for p in get_image_files(images_dir) if (labels_dir / f"{p.stem}.txt").exists()]
|
|
71
|
+
if len(labeled) < 2:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Need at least 2 labeled images to train; found {len(labeled)}. "
|
|
74
|
+
"Label more images in `review` first."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
train_set, val_set = _split(labeled, val_split)
|
|
78
|
+
|
|
79
|
+
out_path = Path(name)
|
|
80
|
+
if out_path.suffix != ".pt":
|
|
81
|
+
out_path = out_path.with_suffix(".pt")
|
|
82
|
+
|
|
83
|
+
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)
|
|
85
|
+
|
|
86
|
+
from ultralytics import YOLO
|
|
87
|
+
model = YOLO(base)
|
|
88
|
+
model.train(
|
|
89
|
+
data=str(data_yaml),
|
|
90
|
+
epochs=epochs,
|
|
91
|
+
imgsz=imgsz,
|
|
92
|
+
project=str(work_dir / "runs"),
|
|
93
|
+
name="train",
|
|
94
|
+
exist_ok=True,
|
|
95
|
+
verbose=False,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
best = work_dir / "runs" / "train" / "weights" / "best.pt"
|
|
99
|
+
if not best.exists():
|
|
100
|
+
raise RuntimeError("Training finished but best.pt was not produced.")
|
|
101
|
+
shutil.copy(best, out_path)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"model": str(out_path),
|
|
105
|
+
"classes": names,
|
|
106
|
+
"train": len(train_set),
|
|
107
|
+
"val": len(val_set),
|
|
108
|
+
"epochs": epochs,
|
|
109
|
+
"dataset": str(work_dir),
|
|
110
|
+
}
|
magiclabel/utils.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
def get_image_files(source: Path) -> List[Path]:
|
|
6
|
+
exts = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"}
|
|
7
|
+
return sorted([p for p in source.iterdir() if p.suffix.lower() in exts])
|
|
8
|
+
|
|
9
|
+
def suppress_ultralytics_output():
|
|
10
|
+
"""Reduce Ultralytics logging verbosity."""
|
|
11
|
+
os.environ["YOLO_VERBOSE"] = "False"
|
|
12
|
+
os.environ["ULTRALYTICS_SILENT"] = "true"
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magiclabel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-assisted image annotation CLI for computer vision datasets
|
|
5
|
+
Author: Rohan Rustagi
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: annotation,labeling,computer-vision,yolo,dataset,object-detection
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: ultralytics>=8.0.0
|
|
19
|
+
Requires-Dist: typer>=0.9.0
|
|
20
|
+
Requires-Dist: rich
|
|
21
|
+
Requires-Dist: opencv-python
|
|
22
|
+
Requires-Dist: gradio
|
|
23
|
+
Requires-Dist: gradio_image_annotation
|
|
24
|
+
Requires-Dist: torch
|
|
25
|
+
Requires-Dist: torchvision
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# MagicLabel
|
|
29
|
+
|
|
30
|
+
> AI‑assisted image labeling CLI — go from raw images to your own trained model, end to end.
|
|
31
|
+
|
|
32
|
+
Good models start with good data, and labeling that data by hand is the slowest part of any computer‑vision project. **MagicLabel** runs the whole loop from one command line:
|
|
33
|
+
|
|
34
|
+
1. **Auto‑detect** objects in a folder of images — including custom objects you describe in plain words (no training needed).
|
|
35
|
+
2. **Review & fix** the labels in a browser — draw, move, resize, relabel, delete.
|
|
36
|
+
3. **Train** a custom YOLO model on your labeled data.
|
|
37
|
+
4. **Reuse** your named model (`rohan.pt`, `findelon.pt`, …) to auto‑label the next batch — the loop closes.
|
|
38
|
+
|
|
39
|
+
Everything runs **locally and free** — no account, no cloud upload, no Docker.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- 🔍 **Auto‑detect** — label an image folder with a YOLO model (the 80 COCO classes out of the box).
|
|
49
|
+
- 🗣️ **Open‑vocabulary detect** — `--prompt "missile,launcher"` detects *anything you name*, with no training (YOLO‑World).
|
|
50
|
+
- 🖊️ **Visual review** — a Gradio web app to draw / move / resize / relabel / delete boxes and save back to YOLO format. Type **any class name you want**.
|
|
51
|
+
- 🎓 **Train** — fine‑tune a custom model on your labeled data and save it as a named `.pt`.
|
|
52
|
+
- 🔄 **Export** — convert YOLO labels to COCO JSON.
|
|
53
|
+
- 🏷️ **Inspect** — print the class names a YOLO model knows.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Who is this for?
|
|
58
|
+
|
|
59
|
+
Anyone who needs a labeled image dataset and a model to train on it:
|
|
60
|
+
|
|
61
|
+
- **ML engineers & researchers** bootstrapping a dataset from raw images.
|
|
62
|
+
- **Hobbyists & students** learning the full CV pipeline without paying for a labeling service.
|
|
63
|
+
- **Privacy‑sensitive / air‑gapped users** (medical, defense, proprietary data) who can't upload images to a cloud tool.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
- **Python 3.8+**
|
|
70
|
+
- Disk space for model weights (downloaded automatically on first use)
|
|
71
|
+
- Works on **CPU**; a CUDA or Apple‑Silicon (MPS) GPU is used automatically when available and is much faster for training
|
|
72
|
+
|
|
73
|
+
> **Heads‑up:** MagicLabel depends on `torch` and `ultralytics`, so installation pulls ~1 GB of packages. That's normal for a computer‑vision tool.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
### From PyPI
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install magiclabel
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### From source
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git clone https://github.com/your-username/magiclabel.git
|
|
89
|
+
cd magiclabel
|
|
90
|
+
python -m venv .venv
|
|
91
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
92
|
+
pip install -e .
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Verify:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
magiclabel --help
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Quick start — the full loop
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# 1. Auto-label a folder of images (closed-set, 80 COCO classes)
|
|
107
|
+
magiclabel detect ./images --output ./labels
|
|
108
|
+
|
|
109
|
+
# …or detect a CUSTOM object by describing it (no training):
|
|
110
|
+
magiclabel detect ./images --prompt "missile, launcher" --output ./labels
|
|
111
|
+
|
|
112
|
+
# 2. Review and fix the labels in your browser
|
|
113
|
+
magiclabel review ./images ./labels
|
|
114
|
+
|
|
115
|
+
# 3. Train your own model on the corrected dataset
|
|
116
|
+
magiclabel train ./images ./labels --name rohan.pt --epochs 50
|
|
117
|
+
|
|
118
|
+
# 4. Reuse your model to auto-label the next batch
|
|
119
|
+
magiclabel detect ./new_images --model rohan.pt --output ./labels2
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Commands
|
|
125
|
+
|
|
126
|
+
Run `magiclabel <command> --help` at any time for the full option list.
|
|
127
|
+
|
|
128
|
+
### `detect` — auto‑annotate with bounding boxes
|
|
129
|
+
|
|
130
|
+
Runs a model over every image in a directory and writes one YOLO‑format `.txt` per image, plus a `classes.txt` (the class list).
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
magiclabel detect SOURCE [OPTIONS]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| Option | Default | Description |
|
|
137
|
+
| ------------ | ---------------------------------------- | --------------------------------------------------------------------------- |
|
|
138
|
+
| `SOURCE` | _(required)_ | Directory of images (`.jpg`, `.jpeg`, `.png`, `.bmp`, `.tif`) |
|
|
139
|
+
| `--prompt` | _(none)_ | Comma‑separated object names for **open‑vocabulary** detection, e.g. `"missile,launcher"`. No training needed. |
|
|
140
|
+
| `--model` | `yolov8n.pt` / `yolov8s-world.pt` | Model file. Defaults to `yolov8n.pt` (closed‑set), or `yolov8s-world.pt` when `--prompt` is given. Pass your own `.pt` to use a trained model. |
|
|
141
|
+
| `--output` | `./labels_detect` | Output directory for labels + `classes.txt` |
|
|
142
|
+
| `--conf` | `0.25` | Confidence threshold (0–1); higher = fewer, surer boxes |
|
|
143
|
+
|
|
144
|
+
**Examples**
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# Closed-set: the 80 COCO classes
|
|
148
|
+
magiclabel detect ./images --conf 0.3 --output ./labels
|
|
149
|
+
|
|
150
|
+
# Open-vocabulary: detect objects YOLO has never been trained on
|
|
151
|
+
magiclabel detect ./images --prompt "drone, fixed-wing aircraft" --conf 0.1
|
|
152
|
+
|
|
153
|
+
# Use your own trained model
|
|
154
|
+
magiclabel detect ./images --model rohan.pt --output ./labels
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
> **Open‑vocabulary is great for common/clear objects and hit‑or‑miss on unusual ones** (it does the easy 70–80%). For hard objects (e.g. missiles), hand‑label a first batch in `review`, `train` a model, then `detect --model your.pt` to auto‑label the rest.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### `review` — fix annotations in your browser
|
|
162
|
+
|
|
163
|
+
Launches a local web app (default: <http://127.0.0.1:7860>) to inspect and correct labels.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
magiclabel review IMAGES_DIR LABELS_DIR [--model yolov8n.pt]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Argument / Option | Description |
|
|
170
|
+
| ----------------- | --------------------------------------------------------------------------- |
|
|
171
|
+
| `IMAGES_DIR` | Directory of images |
|
|
172
|
+
| `LABELS_DIR` | Directory of YOLO `.txt` labels (and `classes.txt`) |
|
|
173
|
+
| `--model` | Model whose class names label the boxes (default `yolov8n.pt`). Use `--model none` to start a clean custom dataset with no preset names. |
|
|
174
|
+
|
|
175
|
+
**In the UI you can:**
|
|
176
|
+
|
|
177
|
+
- **Draw** a new box — click and drag on the image.
|
|
178
|
+
- **Move / resize** — click a box and drag it or its corner handles.
|
|
179
|
+
- **Set the class** — double‑click a box and **type any class name** (`missile`, `crack`, `logo`), then click the editor's apply/✓ to commit. New names are added automatically.
|
|
180
|
+
- **Delete** — the box's remove button, the <kbd>Delete</kbd> key, or **Clear All Boxes**.
|
|
181
|
+
- **Switch images** — the dropdown at the top (it opens on the first image that already has boxes).
|
|
182
|
+
- **Save Changes** — writes boxes back to the `.txt`; the view then reloads from disk so you can confirm exactly what was saved.
|
|
183
|
+
|
|
184
|
+
**Custom classes & `classes.txt`:** YOLO label files store integer class ids, so MagicLabel keeps a `classes.txt` (one name per line, id = line number) in your labels folder. Whatever name you type is mapped to a stable id behind the scenes, and new names are appended automatically. **That `classes.txt` is the class list you train on.**
|
|
185
|
+
|
|
186
|
+
> Edits live only in the browser until you click **Save Changes**. Saving an image with no boxes writes an empty label file (a valid "no objects" annotation).
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### `train` — train your own model
|
|
191
|
+
|
|
192
|
+
Fine‑tunes a YOLO model on your labeled dataset and saves it as a named `.pt`.
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
magiclabel train IMAGES_DIR LABELS_DIR [OPTIONS]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| Option | Default | Description |
|
|
199
|
+
| -------------- | ------------- | ------------------------------------------------------ |
|
|
200
|
+
| `IMAGES_DIR` | _(required)_ | Directory of images |
|
|
201
|
+
| `LABELS_DIR` | _(required)_ | Directory of YOLO labels + `classes.txt` |
|
|
202
|
+
| `--name` | `custom.pt` | Output model name, e.g. `rohan.pt` |
|
|
203
|
+
| `--base` | `yolov8n.pt` | Base model to fine‑tune |
|
|
204
|
+
| `--epochs` | `50` | Training epochs |
|
|
205
|
+
| `--imgsz` | `640` | Training image size |
|
|
206
|
+
| `--val-split` | `0.2` | Fraction of images held out for validation |
|
|
207
|
+
|
|
208
|
+
**Example**
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
magiclabel train ./images ./labels --name rohan.pt --epochs 100
|
|
212
|
+
# → rohan.pt (and a rohan_dataset/ folder with the train/val split + data.yaml)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The command builds a proper YOLO dataset (train/val split + `data.yaml`) from your labels, trains, and copies the best weights to your named file. Use it anywhere a model is accepted:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
magiclabel detect ./new_images --model rohan.pt
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
> A useful model needs **dozens‑to‑hundreds of labeled images** and enough epochs. On CPU this can be slow — a GPU is much faster. Start small to validate the pipeline, then scale your data.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### `yolo2coco` — convert labels to COCO JSON
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
magiclabel yolo2coco IMAGES_DIR LABELS_DIR OUTPUT_JSON --class NAME [--class NAME ...]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
| Argument / Option | Description |
|
|
232
|
+
| ----------------- | --------------------------------------------------------- |
|
|
233
|
+
| `IMAGES_DIR` | Directory of images |
|
|
234
|
+
| `LABELS_DIR` | Directory of YOLO detection labels |
|
|
235
|
+
| `OUTPUT_JSON` | Path to write the COCO JSON file |
|
|
236
|
+
| `--class` | Class names **in class‑id order** (0, 1, 2, …), repeated |
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
magiclabel yolo2coco ./images ./labels annotations.json \
|
|
240
|
+
--class person --class bicycle --class car
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### `export-classes` — list a model's class names
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
magiclabel export-classes yolov8n.pt
|
|
249
|
+
# 0: person
|
|
250
|
+
# 1: bicycle
|
|
251
|
+
# 2: car
|
|
252
|
+
# ...
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Label format
|
|
258
|
+
|
|
259
|
+
MagicLabel reads and writes the **YOLO detection** format: one `.txt` per image, sharing the image's base name (`dog.jpg` → `dog.txt`). Each line is one box:
|
|
260
|
+
|
|
261
|
+
```text
|
|
262
|
+
<class_id> <x_center> <y_center> <width> <height>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
All four coordinates are **normalized to 0–1** relative to the image size. Example:
|
|
266
|
+
|
|
267
|
+
```text
|
|
268
|
+
16 0.593846 0.400000 0.250769 0.258462
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A `classes.txt` alongside the labels maps each id to a name (id = line number).
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Tips & troubleshooting
|
|
276
|
+
|
|
277
|
+
- **Model downloads:** the first run of a new model downloads its weights into the current directory and reuses them after.
|
|
278
|
+
- **Speed:** detection is fast on CPU; training is much faster on a GPU (PyTorch uses CUDA/MPS automatically).
|
|
279
|
+
- **"No images found":** the source must be a directory containing images with a supported extension (`.jpg`, `.jpeg`, `.png`, `.bmp`, `.tif`, `.tiff`).
|
|
280
|
+
- **Port 7860 already in use:** an old review server is still running. Stop it with:
|
|
281
|
+
```bash
|
|
282
|
+
kill $(lsof -nP -tiTCP:7860 -sTCP:LISTEN)
|
|
283
|
+
```
|
|
284
|
+
- **Restart after changes:** the review server does not hot‑reload — stop it (<kbd>Ctrl</kbd>+<kbd>C</kbd>) and run `magiclabel review …` again.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Project structure
|
|
289
|
+
|
|
290
|
+
```text
|
|
291
|
+
magiclabel/
|
|
292
|
+
├── cli.py # Typer CLI entry point (detect, review, train, yolo2coco, export-classes)
|
|
293
|
+
├── annotator.py # YOLO / YOLO-World detection → label files + classes.txt
|
|
294
|
+
├── review_app.py # Gradio review/annotation web app
|
|
295
|
+
├── trainer.py # builds the dataset and trains a custom model
|
|
296
|
+
├── convertor.py # YOLO → COCO conversion
|
|
297
|
+
└── utils.py # image discovery + helpers
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
magiclabel/__init__.py,sha256=tuRtv5HV8puvzk36ySyUdVbw33UpC9I-KGlFu9Aco_k,101
|
|
2
|
+
magiclabel/annotator.py,sha256=NMKWdhyavtjp8es5QvM-hFPPRc-FVUNB83STkfOyB10,3292
|
|
3
|
+
magiclabel/cli.py,sha256=dDRhGHvFziDBKKUS40qFH2qGRSkTkhHFgWt3IZfUYOU,4947
|
|
4
|
+
magiclabel/convertor.py,sha256=kwRo4c2WC_uAwTnHEEyv1E0lTYcD5neTBpymfnmGnNk,3619
|
|
5
|
+
magiclabel/review_app.py,sha256=3NoW8O03GYhlb1K6TwGvJa9-4-dWyAPzYALEvS6X8fc,8366
|
|
6
|
+
magiclabel/trainer.py,sha256=JJt95f4f2ad0eYcAn52BPWwL8YuMsvmIDCMXfK_xYvk,3841
|
|
7
|
+
magiclabel/utils.py,sha256=8aoyGTQdnG400s45acUvyNKSahGtVCo6YWWQ4ELcApI,418
|
|
8
|
+
magiclabel-0.1.0.dist-info/licenses/LICENSE,sha256=bp4gXAoWuFqpaEwggOuI1vpNMrHzDnTvVgpXSaj0z30,1070
|
|
9
|
+
magiclabel-0.1.0.dist-info/METADATA,sha256=Oq7fb7jR7iwLAkru89saPT9x8YoKLRe-ytc1UtXSLMQ,12431
|
|
10
|
+
magiclabel-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
magiclabel-0.1.0.dist-info/entry_points.txt,sha256=NB73OtQ4Ix7tsCbq6qCJ_SCdfqAMLcPJ6Deq4IFwfXA,50
|
|
12
|
+
magiclabel-0.1.0.dist-info/top_level.txt,sha256=cU2tTDmN9wpssViI1CkPkL30We0hW2lbUDuALAC6RvY,11
|
|
13
|
+
magiclabel-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rohan Rustagi
|
|
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
|
+
magiclabel
|