Apiarist Dev commited on
Commit
df673a9
·
1 Parent(s): f4f2593

feat: ship combined-dataset YOLO weights (Matt Nudi + Hendricks; queen mAP 0.99 + better generalization)

Browse files
scripts/train_yolo_combined.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train YOLOv8s on a COMBINED dataset:
3
+ - matt-nudi/honey-bee-detection-model-zgjnb v4 (wide inspection shots)
4
+ - hendricks_ricky-hotmail-de/bee-project v2 (close-up macro shots with mites + queens)
5
+
6
+ This gives us generalization across both image styles. The earlier
7
+ single-dataset run on hendricks_ricky alone scored mAP 0.99 on queens
8
+ but failed on wide-frame photos, because the training distribution
9
+ was too narrow.
10
+
11
+ Class merge plan (both datasets land on these 4 canonical labels):
12
+ Matt Nudi "bee" + Hendricks "Worker Bee" -> Worker Bee (0)
13
+ Matt Nudi "drone" + Hendricks "Drone Bee" -> Drone Bee (1)
14
+ Matt Nudi "queen" + Hendricks "Queen Bee" -> Queen Bee (2)
15
+ Hendricks "Varroa Mite" -> Varroa Mite (3)
16
+ Matt Nudi "pollenbee" -> Worker Bee (treated as worker)
17
+ """
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+ import modal
23
+
24
+
25
+ APP_NAME = "apiarist-yolo-combined"
26
+ VOLUME_NAME = "apiarist-weights"
27
+ EPOCHS = 60
28
+ IMG_SIZE = 640
29
+ BATCH = 32
30
+ BASE_WEIGHTS = "yolov8s.pt"
31
+
32
+ image = (
33
+ modal.Image.debian_slim(python_version="3.11")
34
+ .pip_install(
35
+ "ultralytics==8.3.81",
36
+ "roboflow==1.1.50",
37
+ "pyyaml",
38
+ )
39
+ .apt_install("libgl1", "libglib2.0-0")
40
+ )
41
+
42
+ vol = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
43
+
44
+ app = modal.App(APP_NAME)
45
+
46
+
47
+ # Class index in the combined dataset
48
+ CANONICAL = {
49
+ "Worker Bee": 0,
50
+ "Drone Bee": 1,
51
+ "Queen Bee": 2,
52
+ "Varroa Mite": 3,
53
+ }
54
+
55
+ # Map of source class index -> canonical name, per dataset
56
+ HENDRICKS_MAP = {
57
+ 0: "Drone Bee", # Drone Bee
58
+ 1: "Queen Bee", # Queen Bee
59
+ 2: "Varroa Mite", # Varroa Mite
60
+ 3: "Worker Bee", # Worker Bee
61
+ }
62
+ MATT_MAP = {
63
+ 0: "Worker Bee", # bee
64
+ 1: "Drone Bee", # drone
65
+ 2: "Worker Bee", # pollenbee, treat as worker
66
+ 3: "Queen Bee", # queen
67
+ }
68
+
69
+
70
+ @app.function(
71
+ image=image,
72
+ gpu="T4",
73
+ volumes={"/weights": vol},
74
+ timeout=3 * 60 * 60,
75
+ )
76
+ def train(rf_api_key: str) -> str:
77
+ import shutil
78
+ import sys
79
+ from pathlib import Path
80
+
81
+ from roboflow import Roboflow
82
+ from ultralytics import YOLO
83
+
84
+ print("=" * 60)
85
+ print("Downloading both datasets ...")
86
+ print("=" * 60)
87
+ rf = Roboflow(api_key=rf_api_key)
88
+
89
+ # Hendricks
90
+ hend_proj = rf.workspace("hendricks_ricky-hotmail-de").project("bee-project")
91
+ hend_ds = hend_proj.version(2).download("yolov8", location="/tmp/hendricks")
92
+
93
+ # Matt Nudi
94
+ matt_proj = rf.workspace("matt-nudi").project("honey-bee-detection-model-zgjnb")
95
+ matt_ds = matt_proj.version(4).download("yolov8", location="/tmp/matt_nudi")
96
+
97
+ print(f" hendricks at {hend_ds.location}")
98
+ print(f" matt_nudi at {matt_ds.location}")
99
+
100
+ # Merge into one dataset folder
101
+ merged = Path("/tmp/combined")
102
+ for split in ("train", "valid", "test"):
103
+ (merged / split / "images").mkdir(parents=True, exist_ok=True)
104
+ (merged / split / "labels").mkdir(parents=True, exist_ok=True)
105
+
106
+ def _remap_label_file(src_label: Path, dst_label: Path, src_map: dict):
107
+ """Rewrite a YOLO label file with remapped class indices.
108
+ Drops boxes whose source class doesn't appear in src_map."""
109
+ lines_out = []
110
+ for line in src_label.read_text().splitlines():
111
+ parts = line.strip().split()
112
+ if not parts:
113
+ continue
114
+ try:
115
+ cls_id = int(parts[0])
116
+ except ValueError:
117
+ continue
118
+ canonical_name = src_map.get(cls_id)
119
+ if canonical_name is None:
120
+ continue
121
+ new_id = CANONICAL[canonical_name]
122
+ lines_out.append(" ".join([str(new_id)] + parts[1:]))
123
+ dst_label.write_text("\n".join(lines_out) + ("\n" if lines_out else ""))
124
+
125
+ def _absorb(src_root: Path, src_map: dict, prefix: str):
126
+ for split in ("train", "valid", "test"):
127
+ img_dir = src_root / split / "images"
128
+ lbl_dir = src_root / split / "labels"
129
+ if not img_dir.exists():
130
+ continue
131
+ for img in img_dir.iterdir():
132
+ stem = img.stem
133
+ new_img = merged / split / "images" / f"{prefix}_{img.name}"
134
+ new_lbl = merged / split / "labels" / f"{prefix}_{stem}.txt"
135
+ shutil.copy(img, new_img)
136
+ src_lbl = lbl_dir / f"{stem}.txt"
137
+ if src_lbl.exists():
138
+ _remap_label_file(src_lbl, new_lbl, src_map)
139
+
140
+ print("\nAbsorbing Matt Nudi ...")
141
+ _absorb(Path(matt_ds.location), MATT_MAP, "mnudi")
142
+ print("Absorbing Hendricks ...")
143
+ _absorb(Path(hend_ds.location), HENDRICKS_MAP, "hendr")
144
+
145
+ # Write the combined data.yaml
146
+ yaml_text = (
147
+ "train: ../train/images\n"
148
+ "val: ../valid/images\n"
149
+ "test: ../test/images\n"
150
+ "nc: 4\n"
151
+ "names: ['Worker Bee', 'Drone Bee', 'Queen Bee', 'Varroa Mite']\n"
152
+ )
153
+ (merged / "data.yaml").write_text(yaml_text)
154
+ print(f"\nCombined dataset ready at {merged}")
155
+ for split in ("train", "valid", "test"):
156
+ n_img = len(list((merged / split / "images").iterdir()))
157
+ n_lbl = len(list((merged / split / "labels").iterdir()))
158
+ print(f" {split}: {n_img} images, {n_lbl} labels")
159
+
160
+ print("\n" + "=" * 60)
161
+ print(f"Training YOLOv8s for {EPOCHS} epochs ...")
162
+ print("=" * 60)
163
+ model = YOLO(BASE_WEIGHTS)
164
+ model.train(
165
+ data=str(merged / "data.yaml"),
166
+ epochs=EPOCHS,
167
+ imgsz=IMG_SIZE,
168
+ batch=BATCH,
169
+ project="/weights",
170
+ name="apiarist_combined",
171
+ exist_ok=True,
172
+ device=0,
173
+ patience=15,
174
+ )
175
+
176
+ best_pt = Path("/weights/apiarist_combined/weights/best.pt")
177
+ if not best_pt.exists():
178
+ print("ERROR: best.pt not found after training", file=sys.stderr)
179
+ sys.exit(1)
180
+
181
+ size_mb = best_pt.stat().st_size / 1024 / 1024
182
+ print(f"\n[OK] best.pt saved at {best_pt} ({size_mb:.1f} MB)")
183
+ vol.commit()
184
+ return str(best_pt)
185
+
186
+
187
+ @app.local_entrypoint()
188
+ def main() -> None:
189
+ from dotenv import load_dotenv
190
+
191
+ load_dotenv()
192
+ api_key = os.environ.get("ROBOFLOW_API_KEY")
193
+ if not api_key:
194
+ raise SystemExit(
195
+ "Missing ROBOFLOW_API_KEY in .env. Add it before running."
196
+ )
197
+ print("Kicking off combined Modal training ...")
198
+ weights_path = train.remote(rf_api_key=api_key)
199
+ print("\n" + "=" * 60)
200
+ print(f"DONE. Weights at: {weights_path}")
201
+ print("=" * 60)
202
+ print(
203
+ "\nDownload locally with:\n"
204
+ f" modal volume get {VOLUME_NAME} /apiarist_combined/weights/best.pt "
205
+ f"weights/honey_bee_detector.pt"
206
+ )
weights/honey_bee_detector.pt CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:fcb8bdb223f2f079c3dc3dd577406a6a5f707a08d43404a4fd4657120e2e64b2
3
- size 22531882
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:894b7a41c9ad05fab487158c66f49ea521ff1543b55948bfc0487bce1ad7c2b9
3
+ size 22521386