Apiarist Dev commited on
Commit
7119114
·
1 Parent(s): 9af6826

feat: ranked top-3 queen candidates with probability shown - honest AI, user verifies

Browse files
Files changed (5) hide show
  1. .gitignore +1 -0
  2. app.py +10 -4
  3. cascade.py +29 -9
  4. detector.py +22 -13
  5. scripts/push_classifier_to_hub.py +132 -0
.gitignore CHANGED
@@ -14,3 +14,4 @@ weights/*
14
  !sample_photos/
15
 
16
  .env
 
 
14
  !sample_photos/
15
 
16
  .env
17
+ debug_photos/
app.py CHANGED
@@ -100,12 +100,17 @@ def parse_response(text: str, hive_name: str) -> dict:
100
 
101
 
102
  def build_narrative(r: dict, raw: str) -> str:
 
103
  if r["queen_detected"]:
104
- queen_line = "Queen detected (specialist classifier)"
105
- elif r.get("queen_candidate"):
106
  queen_line = (
107
- f"Likely queen flagged in cyan - **confirm by eye** "
108
- f"(stands out by {r.get('queen_standout', 0):.1f} vs the other bees)"
 
 
 
 
 
 
109
  )
110
  else:
111
  queen_line = (
@@ -281,6 +286,7 @@ def analyze_frame(image: Image.Image, hive_name: str):
281
  results["detector_used"] = True
282
  results["cascade_grid_size"] = cascade_info.get("grid_size", 0)
283
  results["cascade_queen_idx"] = sorted(cascade_info.get("queen_indices", set()))
 
284
  else:
285
  results["detector_used"] = False
286
 
 
100
 
101
 
102
  def build_narrative(r: dict, raw: str) -> str:
103
+ top_probs = r.get("cascade_top_probs", [])
104
  if r["queen_detected"]:
 
 
105
  queen_line = (
106
+ f"**Queen detected** (specialist classifier, {int(top_probs[0]*100)}% confidence)"
107
+ if top_probs else "**Queen detected** (specialist classifier)"
108
+ )
109
+ elif r.get("queen_candidate") and top_probs:
110
+ cand_strs = [f"{int(p*100)}%" for p in top_probs[:3] if p >= 0.5]
111
+ queen_line = (
112
+ f"No high-confidence queen, but top candidates flagged in cyan "
113
+ f"({', '.join(cand_strs)}) - **confirm by eye**"
114
  )
115
  else:
116
  queen_line = (
 
286
  results["detector_used"] = True
287
  results["cascade_grid_size"] = cascade_info.get("grid_size", 0)
288
  results["cascade_queen_idx"] = sorted(cascade_info.get("queen_indices", set()))
289
+ results["cascade_top_probs"] = cascade_info.get("top_3_probs", [])
290
  else:
291
  results["detector_used"] = False
292
 
cascade.py CHANGED
@@ -168,29 +168,49 @@ def verify_queens(
168
  if queen_clf.is_available():
169
  crops = [_crop_with_padding(image, d["bbox"]) for d in candidates]
170
  probs = queen_clf.classify_crops(crops)
171
- # Best candidate = highest queen probability
172
- best_idx = max(range(len(probs)), key=lambda i: probs[i]["queen_prob"])
173
- best_prob = probs[best_idx]["queen_prob"]
174
- is_queen = probs[best_idx]["is_queen"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
  new_detections = []
177
  others = [d for d in detections if d not in candidates]
178
  for i, d in enumerate(candidates):
179
  new_d = dict(d)
180
- if i == best_idx and is_queen:
 
181
  new_d["class"] = "queen"
182
- new_d["queen_prob"] = best_prob
183
  else:
184
  new_d["class"] = "bee"
185
- new_d["queen_prob"] = probs[i]["queen_prob"]
 
 
186
  new_detections.append(new_d)
187
  new_detections.extend(others)
188
 
 
189
  return new_detections, {
190
  "method": "classifier",
191
  "n_candidates": len(candidates),
192
- "queen_prob": best_prob,
193
- "queen_found": is_queen,
 
194
  "raw_response": "",
195
  }
196
 
 
168
  if queen_clf.is_available():
169
  crops = [_crop_with_padding(image, d["bbox"]) for d in candidates]
170
  probs = queen_clf.classify_crops(crops)
171
+
172
+ # Rank all candidates by queen probability, descending.
173
+ ranked = sorted(
174
+ range(len(candidates)),
175
+ key=lambda i: probs[i]["queen_prob"],
176
+ reverse=True,
177
+ )
178
+ top_idx = ranked[0] if ranked else None
179
+ top_prob = probs[top_idx]["queen_prob"] if top_idx is not None else 0.0
180
+
181
+ # Class promotion rules:
182
+ # - The single highest-scoring crop above QUEEN_PROB_THRESHOLD -> "queen"
183
+ # - The next two crops above 0.5 -> stay "bee" but tagged as
184
+ # queen_candidate with their probability (drawn as cyan dashed boxes)
185
+ promoted_queen_idx = top_idx if top_prob >= queen_clf.QUEEN_PROB_THRESHOLD else None
186
+ candidate_indices = [
187
+ i for i in ranked[:3]
188
+ if i != promoted_queen_idx
189
+ and probs[i]["queen_prob"] >= 0.50
190
+ ]
191
 
192
  new_detections = []
193
  others = [d for d in detections if d not in candidates]
194
  for i, d in enumerate(candidates):
195
  new_d = dict(d)
196
+ new_d["queen_prob"] = probs[i]["queen_prob"]
197
+ if i == promoted_queen_idx:
198
  new_d["class"] = "queen"
 
199
  else:
200
  new_d["class"] = "bee"
201
+ if i in candidate_indices:
202
+ new_d["queen_candidate"] = True
203
+ new_d["queen_standout"] = probs[i]["queen_prob"]
204
  new_detections.append(new_d)
205
  new_detections.extend(others)
206
 
207
+ top_probs = [probs[i]["queen_prob"] for i in ranked[:3]]
208
  return new_detections, {
209
  "method": "classifier",
210
  "n_candidates": len(candidates),
211
+ "queen_prob": top_prob,
212
+ "queen_found": promoted_queen_idx is not None,
213
+ "top_3_probs": top_probs,
214
  "raw_response": "",
215
  }
216
 
detector.py CHANGED
@@ -294,21 +294,30 @@ def _draw_annotations(image: Image.Image, detections: list[dict]) -> Image.Image
294
  draw.rectangle([bx1, by1, bx2, by2], fill=color + (235,))
295
  draw.text((bx1 + 5, by1 + 1), label, fill=(20, 16, 8), font=font_queen)
296
 
297
- # Best-effort queen candidate (geometric outlier). Drawn last so it sits
298
- # on top, with a dashed cyan box + a question mark - it is a "confirm by
299
- # eye" hint, deliberately NOT styled like the confident green queen box.
300
- cand = next((d for d in detections if d.get("queen_candidate")), None)
301
- if cand is not None:
 
 
 
302
  cx1, cy1, cx2, cy2 = cand["bbox"]
303
- _dashed_rectangle(draw, [cx1, cy1, cx2, cy2],
304
- _CANDIDATE_COLOR + (255,), width=3)
305
- label = "LIKELY QUEEN?"
306
- tw = draw.textlength(label, font=font_queen)
307
- th = font_queen.size + 4
 
 
308
  bx1, by1 = cx1, max(0, cy1 - th - 2)
309
- draw.rectangle([bx1, by1, bx1 + tw + 10, by1 + th],
310
- fill=_CANDIDATE_COLOR + (235,))
311
- draw.text((bx1 + 5, by1 + 1), label, fill=(8, 16, 20), font=font_queen)
 
 
 
 
312
 
313
  return out
314
 
 
294
  draw.rectangle([bx1, by1, bx2, by2], fill=color + (235,))
295
  draw.text((bx1 + 5, by1 + 1), label, fill=(20, 16, 8), font=font_queen)
296
 
297
+ # Queen candidates: every bee tagged by the cascade with
298
+ # queen_candidate=True gets a dashed cyan box and a probability
299
+ # label. Drawn last so they sit on top of the worker bee outlines.
300
+ font_cand = _font(13)
301
+ candidates = [d for d in detections if d.get("queen_candidate")]
302
+ # Sort by probability descending so the highest gets drawn last (on top)
303
+ candidates.sort(key=lambda d: d.get("queen_prob", 0))
304
+ for cand in candidates:
305
  cx1, cy1, cx2, cy2 = cand["bbox"]
306
+ _dashed_rectangle(
307
+ draw, [cx1, cy1, cx2, cy2], _CANDIDATE_COLOR + (255,), width=3
308
+ )
309
+ prob = cand.get("queen_prob") or cand.get("queen_standout", 0)
310
+ label = f"queen? {int(prob * 100)}%"
311
+ tw = draw.textlength(label, font=font_cand)
312
+ th = font_cand.size + 3
313
  bx1, by1 = cx1, max(0, cy1 - th - 2)
314
+ draw.rectangle(
315
+ [bx1, by1, bx1 + tw + 10, by1 + th],
316
+ fill=_CANDIDATE_COLOR + (235,),
317
+ )
318
+ draw.text(
319
+ (bx1 + 5, by1 + 1), label, fill=(8, 16, 20), font=font_cand
320
+ )
321
 
322
  return out
323
 
scripts/push_classifier_to_hub.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Push the trained queen vs worker classifier to its own HF model repo."""
2
+
3
+ import argparse
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+ from huggingface_hub import HfApi
9
+
10
+
11
+ WEIGHTS = Path(__file__).parent.parent / "weights" / "queen_classifier.pt"
12
+
13
+ README = """---
14
+ license: apache-2.0
15
+ library_name: timm
16
+ tags:
17
+ - bees
18
+ - beekeeping
19
+ - image-classification
20
+ - efficientnet
21
+ pipeline_tag: image-classification
22
+ ---
23
+
24
+ # Apiarist Queen-vs-Worker Bee Classifier
25
+
26
+ Binary image classifier (EfficientNet-B0, ~5M params) trained to
27
+ distinguish queen bees from worker bees on cropped bee images.
28
+
29
+ Built as part of [Apiarist](https://huggingface.co/spaces/build-small-hackathon/Apiarist),
30
+ an offline AI hive inspector for backyard beekeepers, made for the
31
+ [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
32
+
33
+ ## Why a dedicated classifier?
34
+
35
+ Multi-class YOLO detectors fight two problems at once (localize + classify)
36
+ and queens lose because they're rare and visually subtle. A focused
37
+ binary classifier on cropped bee images is the right architecture:
38
+ small, fast, trained specifically for one decision.
39
+
40
+ ## Training
41
+
42
+ - Backbone: `efficientnet_b0` (ImageNet pretrained)
43
+ - Training data: bee crops extracted from labelled bounding boxes in two
44
+ Roboflow datasets (Matt Nudi honey bees + Hendricks Ricky bee-project)
45
+ - 1,146 queen crops + 29,825 worker crops, balanced via weighted sampling
46
+ - Heavy augmentation: rotations, flips, color jitter
47
+ - 90/10 train/val split, weighted random sampling for class balance
48
+ - AdamW + cosine schedule, mixed precision on a single T4 GPU
49
+ - Trained on [Modal](https://modal.com)
50
+
51
+ ## Validation metrics
52
+
53
+ - Accuracy: 0.997
54
+ - Precision (queen): 0.991
55
+ - Recall (queen): 0.934
56
+ - **F1: 0.962**
57
+
58
+ ## Recommended use
59
+
60
+ Pair with a bee detector (e.g. YOLOv8). Run the detector first, then
61
+ classify each cropped bee through this model. Threshold queen
62
+ probability at 0.85 for high-precision flagging.
63
+
64
+ ```python
65
+ import torch, timm
66
+ from torchvision import transforms
67
+
68
+ ckpt = torch.load("queen_classifier.pt", map_location="cpu")
69
+ model = timm.create_model(ckpt["arch"], pretrained=False, num_classes=2)
70
+ model.load_state_dict(ckpt["state_dict"])
71
+ model.eval()
72
+
73
+ tf = transforms.Compose([
74
+ transforms.Resize((224, 224)),
75
+ transforms.ToTensor(),
76
+ transforms.Normalize([0.485,0.456,0.406], [0.229,0.224,0.225]),
77
+ ])
78
+
79
+ with torch.no_grad():
80
+ probs = torch.softmax(model(tf(crop).unsqueeze(0)), dim=1)
81
+ queen_idx = ckpt["class_to_idx"]["queen"]
82
+ queen_prob = probs[0, queen_idx].item()
83
+ ```
84
+
85
+ ## Caveats
86
+
87
+ The training distribution leans toward close-up macro photos of bees on
88
+ honeycomb. Generalization to wide-angle inspection photos (with hands /
89
+ background visible) is weaker, since YOLO's bee bounding boxes on those
90
+ photos are often smaller and less precise than the training crops.
91
+
92
+ ## License
93
+
94
+ Apache 2.0. Trained on data released under CC BY 4.0.
95
+ """
96
+
97
+
98
+ def main():
99
+ parser = argparse.ArgumentParser()
100
+ parser.add_argument("--repo-id", required=True)
101
+ parser.add_argument("--private", action="store_true")
102
+ args = parser.parse_args()
103
+
104
+ load_dotenv()
105
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
106
+ if not token:
107
+ raise SystemExit("Set HF_TOKEN in .env")
108
+ if not WEIGHTS.exists():
109
+ raise SystemExit(f"Weights not at {WEIGHTS}")
110
+
111
+ api = HfApi(token=token)
112
+ print(f"Creating repo {args.repo_id} ...")
113
+ api.create_repo(args.repo_id, repo_type="model", private=args.private,
114
+ exist_ok=True)
115
+
116
+ api.upload_file(
117
+ path_or_fileobj=README.encode("utf-8"),
118
+ path_in_repo="README.md",
119
+ repo_id=args.repo_id, repo_type="model",
120
+ commit_message="Add model card",
121
+ )
122
+ api.upload_file(
123
+ path_or_fileobj=str(WEIGHTS),
124
+ path_in_repo="queen_classifier.pt",
125
+ repo_id=args.repo_id, repo_type="model",
126
+ commit_message="Upload EfficientNet-B0 queen classifier weights",
127
+ )
128
+ print(f"\n[OK] Model live at: https://huggingface.co/{args.repo_id}")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()