Apiarist Dev commited on
Commit
e6c0dcb
·
1 Parent(s): c535f2c

feat: 5 sample photos with verified queen detection + disclaimer about operating conditions

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ sample_photos/*.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -11,5 +11,6 @@ data/processed/
11
  weights/*
12
  !weights/honey_bee_detector.pt
13
  !weights/queen_classifier.pt
 
14
 
15
  .env
 
11
  weights/*
12
  !weights/honey_bee_detector.pt
13
  !weights/queen_classifier.pt
14
+ !sample_photos/
15
 
16
  .env
app.py CHANGED
@@ -100,9 +100,17 @@ def parse_response(text: str, hive_name: str) -> dict:
100
 
101
 
102
  def build_narrative(r: dict, raw: str) -> str:
103
- queen_line = (
104
- " Queen detected" if r["queen_detected"] else " No queen visible"
105
- )
 
 
 
 
 
 
 
 
106
  swarm_line = (
107
  " Swarm cells (VLM estimate)"
108
  if r["swarm_cells_detected"]
@@ -247,9 +255,20 @@ def analyze_frame(image: Image.Image, hive_name: str):
247
 
248
  results = parse_response(response, hive_name)
249
 
250
- # Override hallucinated fields with hard YOLO evidence
 
 
 
 
 
 
251
  if yolo_active:
 
252
  results["queen_detected"] = counts.get("queen", 0) > 0
 
 
 
 
253
  results["yolo_counts"] = counts
254
  # Track the top confidence per class so the UI can show how
255
  # confident the model was about each detection.
@@ -533,9 +552,47 @@ def build_ui() -> gr.Blocks:
533
  type="pil",
534
  sources=["upload", "webcam"],
535
  )
 
 
 
 
 
 
536
  analyze_btn = gr.Button(
537
  " Analyze Frame", variant="primary"
538
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  with gr.Column():
540
  annotated_output = gr.Image(label="Annotated Frame")
541
  narrative_output = gr.Markdown()
 
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 = (
112
+ "No queen candidate stood out - she may be hidden or on another frame"
113
+ )
114
  swarm_line = (
115
  " Swarm cells (VLM estimate)"
116
  if r["swarm_cells_detected"]
 
255
 
256
  results = parse_response(response, hive_name)
257
 
258
+ # Override hallucinated fields with hard YOLO evidence.
259
+ # Two distinct, honest signals:
260
+ # queen_detected - a CONFIRMED queen (only the trained classifier sets
261
+ # class="queen"). Hard yes.
262
+ # queen_candidate - a best-effort geometric outlier flagged "confirm by
263
+ # eye". Never a hard yes - the queen may not even be
264
+ # in frame.
265
  if yolo_active:
266
+ cand = next((d for d in detections if d.get("queen_candidate")), None)
267
  results["queen_detected"] = counts.get("queen", 0) > 0
268
+ results["queen_candidate"] = cand is not None
269
+ results["queen_standout"] = (
270
+ cand.get("queen_standout", 0.0) if cand else 0.0
271
+ )
272
  results["yolo_counts"] = counts
273
  # Track the top confidence per class so the UI can show how
274
  # confident the model was about each detection.
 
552
  type="pil",
553
  sources=["upload", "webcam"],
554
  )
555
+ gr.Markdown(
556
+ "*Best with close-up macro shots of frame inspections "
557
+ "(a single bee or small cluster filling the frame). "
558
+ "Wide-angle photos with hands and background may not "
559
+ "reliably detect the queen.*"
560
+ )
561
  analyze_btn = gr.Button(
562
  " Analyze Frame", variant="primary"
563
  )
564
+
565
+ sample_dir = Path(__file__).parent / "sample_photos"
566
+ sample_files = (
567
+ sorted(sample_dir.glob("*.jpg"))
568
+ if sample_dir.exists() else []
569
+ )
570
+ if sample_files:
571
+ gr.Markdown("### Or try a sample photo")
572
+ sample_gallery = gr.Gallery(
573
+ value=[str(p) for p in sample_files],
574
+ label="Click any sample to load it",
575
+ columns=3, height="auto",
576
+ allow_preview=False, show_label=False,
577
+ interactive=False,
578
+ )
579
+
580
+ def _load_sample(evt: gr.SelectData):
581
+ if evt is None or evt.index is None:
582
+ return gr.update()
583
+ idx = (
584
+ evt.index
585
+ if isinstance(evt.index, int)
586
+ else evt.index[0]
587
+ )
588
+ if 0 <= idx < len(sample_files):
589
+ return Image.open(str(sample_files[idx])).convert("RGB")
590
+ return gr.update()
591
+
592
+ sample_gallery.select(
593
+ fn=_load_sample,
594
+ outputs=[image_input],
595
+ )
596
  with gr.Column():
597
  annotated_output = gr.Image(label="Annotated Frame")
598
  narrative_output = gr.Markdown()
cascade.py CHANGED
@@ -25,6 +25,7 @@ from typing import Callable
25
  from PIL import Image, ImageDraw, ImageFont
26
 
27
  import queen_clf
 
28
 
29
 
30
  GRID_SIDE_PX = 240 # each crop tile this size in the composite grid
@@ -193,36 +194,25 @@ def verify_queens(
193
  "raw_response": "",
194
  }
195
 
196
- # ---- Path B: VLM-grid fallback (older approach) ----
197
- if qwen_caller is None or len(candidates) < 2:
198
- return detections, {"method": "skip", "n_candidates": len(candidates),
199
- "queen_prob": 0.0, "raw_response": ""}
200
-
201
- candidates.sort(key=lambda d: _box_area(d["bbox"]), reverse=True)
202
- top = candidates[: MAX_CANDIDATES]
203
- crops = [_crop_with_padding(image, d["bbox"]) for d in top]
204
- grid = _make_grid(crops)
205
- response = qwen_caller(grid, _GRID_PROMPT)
206
- queen_idx_1based = _parse_queen_indices(response)
207
- if len(queen_idx_1based) > 1:
208
- queen_idx_1based = set()
209
- queen_idx_0based = {i - 1 for i in queen_idx_1based if 1 <= i <= len(top)}
210
-
211
- new_detections = []
212
- others = [d for d in detections if d not in candidates]
213
- for i, d in enumerate(top):
214
- new_d = dict(d)
215
- if i in queen_idx_0based:
216
- new_d["class"] = "queen"
217
- new_d["vlm_verified"] = True
218
- else:
219
- new_d["class"] = "bee"
220
- new_detections.append(new_d)
221
- new_detections.extend(others)
222
 
223
  return new_detections, {
224
- "method": "vlm",
225
- "n_candidates": len(top),
226
- "queen_indices": queen_idx_1based,
227
- "raw_response": response,
 
 
 
228
  }
 
25
  from PIL import Image, ImageDraw, ImageFont
26
 
27
  import queen_clf
28
+ import queen_locate
29
 
30
 
31
  GRID_SIDE_PX = 240 # each crop tile this size in the composite grid
 
194
  "raw_response": "",
195
  }
196
 
197
+ # ---- Path B: geometric outlier locator (no weights, no VLM) ----
198
+ #
199
+ # This replaces the old VLM-grid pick, which was unreliable because it
200
+ # asked the model to judge each crop in isolation. Here we keep every
201
+ # bee's class as "bee" and instead TAG the single most queen-like bee
202
+ # as a *candidate* to confirm by eye - judged relative to the other
203
+ # bees on this same frame. If none stands out, nothing is tagged.
204
+ new_detections = [dict(d) for d in detections]
205
+ info, chosen = queen_locate.locate(new_detections)
206
+ if chosen is not None:
207
+ chosen["queen_candidate"] = True
208
+ chosen["queen_standout"] = info["score"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  return new_detections, {
211
+ "method": "geometric",
212
+ "n_candidates": info["n_pool"],
213
+ "queen_candidate": info["candidate"],
214
+ "standout": info["score"],
215
+ "margin": info["margin"],
216
+ "length_ratio": info["length_ratio"],
217
+ "raw_response": "",
218
  }
detector.py CHANGED
@@ -8,6 +8,7 @@ weights file is missing, useful while training is still in progress.
8
 
9
  from __future__ import annotations
10
 
 
11
  import os
12
  import sys
13
  from pathlib import Path
@@ -15,6 +16,12 @@ from typing import Optional
15
 
16
  from PIL import Image, ImageDraw, ImageFont
17
 
 
 
 
 
 
 
18
 
19
  # Resolve the weights file relative to this module's actual location.
20
  _HERE = Path(os.path.dirname(os.path.abspath(__file__)))
@@ -155,7 +162,9 @@ def detect(
155
 
156
  try:
157
  # Cast a wide net at the model level, then filter per-class below.
158
- results = yolo(image, conf=conf, verbose=False, device="cpu")
 
 
159
  if not results:
160
  return [], image
161
  r = results[0]
@@ -209,8 +218,30 @@ def _font(size: int = 14):
209
  return ImageFont.load_default()
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  def draw_annotations(image: Image.Image, detections: list[dict]) -> Image.Image:
213
- """Public wrapper so the VLM cascade can re-annotate after re-classification."""
214
  return _draw_annotations(image, detections)
215
 
216
 
@@ -248,6 +279,22 @@ def _draw_annotations(image: Image.Image, detections: list[dict]) -> Image.Image
248
  draw.rectangle([bx1, by1, bx2, by2], fill=color + (235,))
249
  draw.text((bx1 + 5, by1 + 1), label, fill=(20, 16, 8), font=font_queen)
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  return out
252
 
253
 
 
8
 
9
  from __future__ import annotations
10
 
11
+ import math
12
  import os
13
  import sys
14
  from pathlib import Path
 
16
 
17
  from PIL import Image, ImageDraw, ImageFont
18
 
19
+ # Run inference at a larger size than the YOLO default (640). Bees are tiny
20
+ # relative to a full frame photo; at 640 the queen's distinguishing abdomen
21
+ # length collapses to a couple of pixels. 1280 keeps that detail (and lifts
22
+ # small-bee recall generally) at a modest CPU cost.
23
+ INFER_IMGSZ = 1280
24
+
25
 
26
  # Resolve the weights file relative to this module's actual location.
27
  _HERE = Path(os.path.dirname(os.path.abspath(__file__)))
 
162
 
163
  try:
164
  # Cast a wide net at the model level, then filter per-class below.
165
+ results = yolo(
166
+ image, conf=conf, imgsz=INFER_IMGSZ, verbose=False, device="cpu"
167
+ )
168
  if not results:
169
  return [], image
170
  r = results[0]
 
218
  return ImageFont.load_default()
219
 
220
 
221
+ _CANDIDATE_COLOR = (90, 220, 255) # cyan - distinct from the green of a confirmed queen
222
+
223
+
224
+ def _dashed_rectangle(draw, box, color, width=3, dash=12, gap=8):
225
+ """Draw a dashed rectangle (PIL has no native dashed outline)."""
226
+ x1, y1, x2, y2 = box
227
+ corners = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)]
228
+ for (ax, ay), (bx, by) in zip(corners, corners[1:]):
229
+ length = math.hypot(bx - ax, by - ay)
230
+ if length == 0:
231
+ continue
232
+ n = int(length // (dash + gap)) + 1
233
+ for s in range(n):
234
+ t0 = (s * (dash + gap)) / length
235
+ t1 = min(t0 + dash / length, 1.0)
236
+ draw.line(
237
+ [ax + (bx - ax) * t0, ay + (by - ay) * t0,
238
+ ax + (bx - ax) * t1, ay + (by - ay) * t1],
239
+ fill=color, width=width,
240
+ )
241
+
242
+
243
  def draw_annotations(image: Image.Image, detections: list[dict]) -> Image.Image:
244
+ """Public wrapper so the cascade can re-annotate after re-classification."""
245
  return _draw_annotations(image, detections)
246
 
247
 
 
279
  draw.rectangle([bx1, by1, bx2, by2], fill=color + (235,))
280
  draw.text((bx1 + 5, by1 + 1), label, fill=(20, 16, 8), font=font_queen)
281
 
282
+ # Best-effort queen candidate (geometric outlier). Drawn last so it sits
283
+ # on top, with a dashed cyan box + a question mark - it is a "confirm by
284
+ # eye" hint, deliberately NOT styled like the confident green queen box.
285
+ cand = next((d for d in detections if d.get("queen_candidate")), None)
286
+ if cand is not None:
287
+ cx1, cy1, cx2, cy2 = cand["bbox"]
288
+ _dashed_rectangle(draw, [cx1, cy1, cx2, cy2],
289
+ _CANDIDATE_COLOR + (255,), width=3)
290
+ label = "LIKELY QUEEN?"
291
+ tw = draw.textlength(label, font=font_queen)
292
+ th = font_queen.size + 4
293
+ bx1, by1 = cx1, max(0, cy1 - th - 2)
294
+ draw.rectangle([bx1, by1, bx1 + tw + 10, by1 + th],
295
+ fill=_CANDIDATE_COLOR + (235,))
296
+ draw.text((bx1 + 5, by1 + 1), label, fill=(8, 16, 20), font=font_queen)
297
+
298
  return out
299
 
300
 
queen_locate.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Best-effort unmarked-queen *locator*.
3
+
4
+ Honesty note: there is no reliable way to find an unmarked queen in a
5
+ single top-down frame photo. This module does NOT pretend to. It surfaces
6
+ the single most queen-like bee on the frame as a CANDIDATE to confirm by
7
+ eye, using the one signal that actually carries information:
8
+
9
+ the queen is a *relative* outlier - longer and more elongated than the
10
+ worker bees AROUND her on this same frame.
11
+
12
+ That is why every per-crop / per-grid approach failed before: queen-ness
13
+ is relative, not absolute. A single cropped bee has no scale reference, so
14
+ the classifier/VLM was guessing. Here we judge every bee against the
15
+ population of bees on the *same* frame.
16
+
17
+ Two robust features per detected bee box:
18
+ - length: the longer side of the box (queen's body is longer)
19
+ - elongation: length / breadth (her abdomen extends past the wings, so
20
+ her box is proportionally longer-and-thinner than a worker;
21
+ this also separates her from drones, which are big but stocky)
22
+
23
+ We compute robust z-scores (median / MAD, so a few mislabelled boxes don't
24
+ wreck the stats), combine them, and pick the top bee - but ONLY return it
25
+ if it clears a real standout threshold AND beats the runner-up by a margin
26
+ AND is meaningfully longer than the median bee. Otherwise: no candidate.
27
+ Returning "nothing stood out" when the queen isn't in frame is the whole
28
+ point - it's what stops the app from lying.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from statistics import median
34
+
35
+
36
+ # Classes that could plausibly be the queen. Drones are already separated
37
+ # by YOLO and are stocky (low elongation), so we leave them out of the pool.
38
+ QUEEN_POOL = ("bee", "pollenbee", "queen")
39
+
40
+ MIN_BEES_FOR_STATS = 5 # need a population before "outlier" means anything
41
+ LENGTH_WEIGHT = 1.0 # she is longer than the workers
42
+ ELONGATION_WEIGHT = 0.6 # ...and proportionally longer-and-thinner
43
+ STANDOUT_THRESHOLD = 2.5 # min combined robust z-score to flag at all
44
+ MIN_MARGIN = 0.8 # top bee must beat the runner-up by this much
45
+ MIN_LENGTH_RATIO = 1.25 # top box must be >=25% longer than the median bee
46
+
47
+
48
+ def _mad(values: list[float], med: float) -> float:
49
+ """Median absolute deviation, scaled (~1.4826) to match a std-dev."""
50
+ if not values:
51
+ return 0.0
52
+ return 1.4826 * median([abs(v - med) for v in values])
53
+
54
+
55
+ def _features(det: dict) -> tuple[float, float]:
56
+ x1, y1, x2, y2 = det["bbox"]
57
+ w, h = max(0.0, x2 - x1), max(0.0, y2 - y1)
58
+ length = max(w, h)
59
+ breadth = max(min(w, h), 1e-3)
60
+ return length, length / breadth # (length, elongation)
61
+
62
+
63
+ def locate(detections: list[dict]) -> tuple[dict, dict | None]:
64
+ """
65
+ Pick the single most queen-like bee on the frame, if one stands out.
66
+
67
+ `detections` is judged in place - the returned dict (when not None) is a
68
+ reference INTO `detections`, so the caller can tag it directly.
69
+
70
+ Returns (info, chosen_detection_or_None). `info` carries the standout
71
+ score, margin and length ratio for honest UI messaging.
72
+ """
73
+ pool = [d for d in detections if d["class"] in QUEEN_POOL]
74
+ if len(pool) < MIN_BEES_FOR_STATS:
75
+ return (
76
+ {"method": "geometric", "candidate": False,
77
+ "reason": "too few bees to judge relative size",
78
+ "n_pool": len(pool), "score": 0.0, "margin": 0.0,
79
+ "length_ratio": 0.0},
80
+ None,
81
+ )
82
+
83
+ feats = []
84
+ for d in pool:
85
+ length, elong = _features(d)
86
+ feats.append({"d": d, "length": length, "elong": elong})
87
+
88
+ lengths = [f["length"] for f in feats]
89
+ elongs = [f["elong"] for f in feats]
90
+ med_l = median(lengths)
91
+ med_e = median(elongs)
92
+ mad_l = _mad(lengths, med_l)
93
+ mad_e = _mad(elongs, med_e)
94
+
95
+ for f in feats:
96
+ z_l = (f["length"] - med_l) / mad_l if mad_l > 0 else 0.0
97
+ z_e = (f["elong"] - med_e) / mad_e if mad_e > 0 else 0.0
98
+ f["score"] = LENGTH_WEIGHT * z_l + ELONGATION_WEIGHT * z_e
99
+
100
+ feats.sort(key=lambda f: f["score"], reverse=True)
101
+ best = feats[0]
102
+ runner = feats[1]["score"] if len(feats) > 1 else 0.0
103
+ margin = best["score"] - runner
104
+ length_ratio = best["length"] / med_l if med_l > 0 else 0.0
105
+
106
+ confident = (
107
+ best["score"] >= STANDOUT_THRESHOLD
108
+ and margin >= MIN_MARGIN
109
+ and length_ratio >= MIN_LENGTH_RATIO
110
+ )
111
+
112
+ info = {
113
+ "method": "geometric",
114
+ "candidate": confident,
115
+ "n_pool": len(pool),
116
+ "score": round(best["score"], 2),
117
+ "margin": round(margin, 2),
118
+ "length_ratio": round(length_ratio, 2),
119
+ }
120
+ return (info, best["d"]) if confident else (info, None)
sample_photos/01_queen_100pct_30bees.jpg ADDED

Git LFS Details

  • SHA256: de8b16b0514d0a7c680bb317d501437926ef08d2c7d8eb6bba145a7b8b6de6f8
  • Pointer size: 131 Bytes
  • Size of remote file: 163 kB
sample_photos/02_queen_100pct_56bees.jpg ADDED

Git LFS Details

  • SHA256: 19cc36f72a4a9ed35d2c1a5b2ece16fba0d7b1dc058c2666b3ea16a5cbd51d17
  • Pointer size: 131 Bytes
  • Size of remote file: 235 kB
sample_photos/03_queen_100pct_12bees.jpg ADDED

Git LFS Details

  • SHA256: 66e910b271e4ebc0eae7d8403d399dd5127bcde90a7a84b723fd7996fb2b3a59
  • Pointer size: 130 Bytes
  • Size of remote file: 85.3 kB
sample_photos/04_queen_100pct_26bees.jpg ADDED

Git LFS Details

  • SHA256: 55bbe1eb3693c47fcf92011e628d6fc4ea07ed9517e1a6cad4e970bb550add49
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
sample_photos/05_queen_100pct_11bees.jpg ADDED

Git LFS Details

  • SHA256: 927cb110cd8b046fb2aabc31d429a112231cb386618403567b503588197f3aa1
  • Pointer size: 130 Bytes
  • Size of remote file: 85.6 kB