Apiarist Dev commited on
Commit
027ff29
·
1 Parent(s): be1617f

feat: SQLite hive registry + inspection history tab, persistent across sessions

Browse files
.gitignore CHANGED
@@ -9,3 +9,5 @@ __pycache__/
9
  data/raw/
10
  data/processed/
11
  weights/
 
 
 
9
  data/raw/
10
  data/processed/
11
  weights/
12
+
13
+ .env
app.py CHANGED
@@ -1,17 +1,26 @@
1
  """
2
  Apiarist - Offline AI inspector for honeybee hive frames.
3
 
4
- Day 7: upgraded SmolVLM-500M -> Qwen2.5-VL-7B for real vision quality.
5
- ZeroGPU gives us a Blackwell GPU with plenty of VRAM, so 7B is cheap.
 
 
 
6
  """
7
 
8
- import gradio as gr
9
- from PIL import Image
10
  import json
11
  import re
 
 
 
 
 
12
  import torch
 
13
  from transformers import AutoProcessor, AutoModelForImageTextToText
14
 
 
 
15
  # ZeroGPU integration — no-op outside HF Spaces
16
  try:
17
  import spaces
@@ -23,9 +32,8 @@ except ImportError:
23
  return fn
24
 
25
 
26
- # Qwen2.5-VL-3B. State-of-the-art vision-language model at a size that
27
- # comfortably fits on ZeroGPU. SmolVLM-2.25B hallucinated on bee anatomy;
28
- # Qwen-7B crashed the container; this is the sweet spot.
29
  MODEL_ID = "Qwen/Qwen2.5-VL-3B-Instruct"
30
 
31
  _model = None
@@ -33,7 +41,6 @@ _processor = None
33
 
34
 
35
  def get_model():
36
- """Lazy-load model on first call. Stays on CPU until moved by analyze_frame."""
37
  global _model, _processor
38
  if _model is None:
39
  print(f"Loading {MODEL_ID} ...")
@@ -57,11 +64,14 @@ HEALTH: good, watch, or alarm
57
  NOTES: one short sentence describing what you see
58
 
59
  Definitions:
60
- - Queens are noticeably larger bees with elongated abdomens, often appearing distinct from worker bees.
61
- - Varroa mites are small reddish-brown parasites visible on bees or comb cells.
62
  - Swarm cells are peanut-shaped cells hanging from the bottom or edges of the comb.
63
- - Brood pattern is solid when capped cells are tightly packed and consistent, spotty when scattered with empty cells.
64
- - Be honest about uncertainty — only say "yes" when you can clearly see the feature."""
 
 
 
65
 
66
 
67
  def parse_response(text: str, hive_name: str) -> dict:
@@ -95,7 +105,6 @@ def build_narrative(r: dict, raw: str) -> str:
95
  if r["swarm_cells_detected"]
96
  else "✅ No swarm cells"
97
  )
98
-
99
  return f"""**Hive: {r['hive']}**
100
 
101
  {queen_line}
@@ -107,7 +116,7 @@ def build_narrative(r: dict, raw: str) -> str:
107
  **Notes:** {r['notes']}
108
 
109
  ---
110
- *Powered by Qwen2.5-VL-3B on ZeroGPU. Fully local, no cloud APIs.*
111
 
112
  <details><summary>Raw model output</summary>
113
 
@@ -118,10 +127,13 @@ def build_narrative(r: dict, raw: str) -> str:
118
  """
119
 
120
 
 
 
 
121
  @gpu
122
  def analyze_frame(image: Image.Image, hive_name: str):
123
  if image is None:
124
- return None, "Upload a frame photo first.", ""
125
 
126
  model, processor = get_model()
127
 
@@ -146,8 +158,7 @@ def analyze_frame(image: Image.Image, hive_name: str):
146
  add_generation_prompt=True,
147
  return_dict=True,
148
  return_tensors="pt",
149
- )
150
- inputs = inputs.to(device)
151
 
152
  with torch.no_grad():
153
  generated = model.generate(
@@ -162,11 +173,97 @@ def analyze_frame(image: Image.Image, hive_name: str):
162
  skip_special_tokens=True,
163
  )[0].strip()
164
  except Exception as e:
165
- return image, f"Model inference failed: {type(e).__name__}: {e}", ""
 
 
 
 
 
 
166
 
167
  results = parse_response(response, hive_name)
168
  narrative = build_narrative(results, response)
169
- return image, narrative, json.dumps(results, indent=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  custom_css = """
@@ -185,65 +282,155 @@ button.primary {
185
  .gr-box, .block { border-color: #f4a300 !important; }
186
  """
187
 
188
- with gr.Blocks(title="Apiarist - Hive Frame Inspector") as app:
189
- gr.Markdown("# 🐝 APIARIST")
190
- gr.Markdown(
191
- "*Offline AI inspector for honeybee hive frames. "
192
- "Built for the Build Small Hackathon.*"
193
- )
194
-
195
- with gr.Tabs():
196
- with gr.Tab("🔍 Inspect"):
197
- with gr.Row():
198
- with gr.Column():
199
- hive_input = gr.Textbox(
200
- label="Hive Number / Name",
201
- placeholder="e.g., Hive #7",
202
- )
203
- image_input = gr.Image(
204
- label="Frame Photo",
205
- type="pil",
206
- sources=["upload", "webcam"],
207
- )
208
- analyze_btn = gr.Button(
209
- "🔬 Analyze Frame", variant="primary"
210
- )
211
- with gr.Column():
212
- annotated_output = gr.Image(label="Annotated Frame")
213
- narrative_output = gr.Markdown()
214
- with gr.Accordion("Raw JSON", open=False):
215
- json_output = gr.Code(language="json")
216
-
217
- analyze_btn.click(
218
- fn=analyze_frame,
219
- inputs=[image_input, hive_input],
220
- outputs=[annotated_output, narrative_output, json_output],
221
- )
222
 
223
- with gr.Tab("📋 Hives"):
224
- gr.Markdown(
225
- "### Your Apiary\n"
226
- "*Hive registry and inspection history (coming soon)*"
227
- )
228
 
229
- with gr.Tab("⚖️ Compare"):
230
- gr.Markdown(
231
- "### Specialist vs Generalist\n"
232
- "*Side-by-side: Apiarist vs cloud VLM (coming soon)*"
233
- )
 
234
 
235
- with gr.Tab("ℹ️ About"):
236
- gr.Markdown(
237
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  **Apiarist** is a fully-offline vision AI for backyard beekeepers.
239
 
240
  - 🔌 No cloud APIs — runs entirely on the laptop
241
- - 🎯 Vision-language model fine-tuned for honeybees
242
  - 📓 Built in 10 days for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon)
243
 
244
- **Stack**: Qwen2.5-VL-3B on ZeroGPU, served via Gradio.
245
  """
246
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
 
249
  if __name__ == "__main__":
 
1
  """
2
  Apiarist - Offline AI inspector for honeybee hive frames.
3
 
4
+ Stack:
5
+ - Qwen2.5-VL-3B on ZeroGPU for the narrative pass.
6
+ - (Coming) custom-trained YOLOv8s for queen / drone / bee detection.
7
+ - SQLite for hive registry + inspection history.
8
+ - Gradio with custom field-tool theme.
9
  """
10
 
 
 
11
  import json
12
  import re
13
+ import sqlite3
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import gradio as gr
18
  import torch
19
+ from PIL import Image
20
  from transformers import AutoProcessor, AutoModelForImageTextToText
21
 
22
+ import db
23
+
24
  # ZeroGPU integration — no-op outside HF Spaces
25
  try:
26
  import spaces
 
32
  return fn
33
 
34
 
35
+ # ---------------------------------------------------------------- model setup
36
+
 
37
  MODEL_ID = "Qwen/Qwen2.5-VL-3B-Instruct"
38
 
39
  _model = None
 
41
 
42
 
43
  def get_model():
 
44
  global _model, _processor
45
  if _model is None:
46
  print(f"Loading {MODEL_ID} ...")
 
64
  NOTES: one short sentence describing what you see
65
 
66
  Definitions:
67
+ - Queens are noticeably larger bees with elongated abdomens.
68
+ - Varroa mites are small reddish-brown parasites on bees or comb cells.
69
  - Swarm cells are peanut-shaped cells hanging from the bottom or edges of the comb.
70
+ - Brood pattern is solid when capped cells are tightly packed, spotty when scattered.
71
+ - Only say "yes" when you can clearly see the feature."""
72
+
73
+
74
+ # ---------------------------------------------------------------- parsing
75
 
76
 
77
  def parse_response(text: str, hive_name: str) -> dict:
 
105
  if r["swarm_cells_detected"]
106
  else "✅ No swarm cells"
107
  )
 
108
  return f"""**Hive: {r['hive']}**
109
 
110
  {queen_line}
 
116
  **Notes:** {r['notes']}
117
 
118
  ---
119
+ *Powered by Qwen2.5-VL-3B on ZeroGPU. Custom YOLO detector lands next.*
120
 
121
  <details><summary>Raw model output</summary>
122
 
 
127
  """
128
 
129
 
130
+ # ---------------------------------------------------------------- inference
131
+
132
+
133
  @gpu
134
  def analyze_frame(image: Image.Image, hive_name: str):
135
  if image is None:
136
+ return None, "Upload a frame photo first.", "", _hives_table_state(), gr.update()
137
 
138
  model, processor = get_model()
139
 
 
158
  add_generation_prompt=True,
159
  return_dict=True,
160
  return_tensors="pt",
161
+ ).to(device)
 
162
 
163
  with torch.no_grad():
164
  generated = model.generate(
 
173
  skip_special_tokens=True,
174
  )[0].strip()
175
  except Exception as e:
176
+ return (
177
+ image,
178
+ f"Model inference failed: {type(e).__name__}: {e}",
179
+ "",
180
+ _hives_table_state(),
181
+ gr.update(),
182
+ )
183
 
184
  results = parse_response(response, hive_name)
185
  narrative = build_narrative(results, response)
186
+
187
+ # Persist the inspection
188
+ hive_id = db.get_or_create_hive(results["hive"])
189
+ db.add_inspection(hive_id, results, raw_response=response)
190
+
191
+ return (
192
+ image,
193
+ narrative,
194
+ json.dumps(results, indent=2),
195
+ _hives_table_state(),
196
+ gr.update(choices=[h["name"] for h in db.list_hives()]),
197
+ )
198
+
199
+
200
+ # ---------------------------------------------------------------- Hives tab helpers
201
+
202
+
203
+ def _hives_table_state() -> list[list]:
204
+ rows = db.list_hives()
205
+ out = []
206
+ for h in rows:
207
+ last = (
208
+ time.strftime("%Y-%m-%d %H:%M", time.localtime(h["last_inspected"]))
209
+ if h["last_inspected"]
210
+ else "—"
211
+ )
212
+ out.append(
213
+ [
214
+ h["name"],
215
+ h.get("location") or "",
216
+ h.get("queen_marker") or "",
217
+ h["inspection_count"],
218
+ last,
219
+ ]
220
+ )
221
+ return out
222
+
223
+
224
+ def add_hive_action(name, location, marker, notes):
225
+ name = (name or "").strip()
226
+ if not name:
227
+ return _hives_table_state(), gr.update(), "⚠️ Name required."
228
+ try:
229
+ db.add_hive(name, location or "", marker or "", notes or "")
230
+ msg = f"✅ Added hive '{name}'."
231
+ except sqlite3.IntegrityError:
232
+ msg = f"⚠️ Hive '{name}' already exists."
233
+ return _hives_table_state(), gr.update(choices=[h["name"] for h in db.list_hives()]), msg
234
+
235
+
236
+ def view_hive_history(hive_name):
237
+ if not hive_name:
238
+ return [], "_Pick a hive above to see its inspection history._"
239
+ hive = next((h for h in db.list_hives() if h["name"] == hive_name), None)
240
+ if not hive:
241
+ return [], "_Hive not found._"
242
+ inspections = db.get_inspections_for_hive(hive["id"])
243
+ if not inspections:
244
+ return [], f"_No inspections recorded for **{hive_name}** yet._"
245
+ rows = []
246
+ for i in inspections:
247
+ rows.append(
248
+ [
249
+ time.strftime("%Y-%m-%d %H:%M", time.localtime(i["created_at"])),
250
+ "Y" if i["queen_detected"] else "N",
251
+ i["varroa_mites_visible"],
252
+ "Y" if i["swarm_cells_detected"] else "N",
253
+ i["frame_health"],
254
+ (i["notes"] or "")[:60],
255
+ ]
256
+ )
257
+ summary = (
258
+ f"### {hive_name}\n"
259
+ f"**Total inspections:** {len(inspections)}\n"
260
+ f"**Last inspected:** "
261
+ f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(inspections[0]['created_at']))}"
262
+ )
263
+ return rows, summary
264
+
265
+
266
+ # ---------------------------------------------------------------- UI
267
 
268
 
269
  custom_css = """
 
282
  .gr-box, .block { border-color: #f4a300 !important; }
283
  """
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
+ def build_ui() -> gr.Blocks:
287
+ db.init_db()
 
 
 
288
 
289
+ with gr.Blocks(title="Apiarist - Hive Frame Inspector") as app:
290
+ gr.Markdown("# 🐝 APIARIST")
291
+ gr.Markdown(
292
+ "*Offline AI inspector for honeybee hive frames. "
293
+ "Built for the Build Small Hackathon.*"
294
+ )
295
 
296
+ with gr.Tabs():
297
+ # ------- INSPECT TAB -------
298
+ with gr.Tab("🔍 Inspect"):
299
+ with gr.Row():
300
+ with gr.Column():
301
+ hive_input = gr.Dropdown(
302
+ label="Hive",
303
+ choices=[h["name"] for h in db.list_hives()],
304
+ allow_custom_value=True,
305
+ info="Pick an existing hive or type a new name.",
306
+ )
307
+ image_input = gr.Image(
308
+ label="Frame Photo",
309
+ type="pil",
310
+ sources=["upload", "webcam"],
311
+ )
312
+ analyze_btn = gr.Button(
313
+ "🔬 Analyze Frame", variant="primary"
314
+ )
315
+ with gr.Column():
316
+ annotated_output = gr.Image(label="Annotated Frame")
317
+ narrative_output = gr.Markdown()
318
+ with gr.Accordion("Raw JSON", open=False):
319
+ json_output = gr.Code(language="json")
320
+
321
+ # ------- HIVES TAB -------
322
+ with gr.Tab("📋 Hives") as hives_tab:
323
+ with gr.Row():
324
+ with gr.Column(scale=1):
325
+ gr.Markdown("### Add a Hive")
326
+ new_name = gr.Textbox(
327
+ label="Name", placeholder="Hive #7"
328
+ )
329
+ new_location = gr.Textbox(
330
+ label="Location (optional)",
331
+ placeholder="South corner of yard",
332
+ )
333
+ new_marker = gr.Dropdown(
334
+ label="Queen marker color (optional)",
335
+ choices=["", "white", "yellow", "red", "green", "blue"],
336
+ value="",
337
+ )
338
+ new_notes = gr.Textbox(
339
+ label="Notes (optional)", lines=2
340
+ )
341
+ add_btn = gr.Button("➕ Add Hive", variant="primary")
342
+ add_msg = gr.Markdown()
343
+
344
+ with gr.Column(scale=2):
345
+ gr.Markdown("### Your Apiary")
346
+ hives_table = gr.Dataframe(
347
+ headers=[
348
+ "Name", "Location", "Queen marker",
349
+ "Inspections", "Last inspected",
350
+ ],
351
+ datatype=["str", "str", "str", "number", "str"],
352
+ interactive=False,
353
+ value=_hives_table_state(),
354
+ wrap=True,
355
+ )
356
+ refresh_btn = gr.Button("🔄 Refresh")
357
+
358
+ gr.Markdown("---\n### Inspection history")
359
+ history_select = gr.Dropdown(
360
+ label="Select a hive",
361
+ choices=[h["name"] for h in db.list_hives()],
362
+ )
363
+ history_summary = gr.Markdown()
364
+ history_table = gr.Dataframe(
365
+ headers=[
366
+ "When", "Queen?", "Mites", "Swarm?",
367
+ "Health", "Notes",
368
+ ],
369
+ datatype=["str", "str", "number", "str", "str", "str"],
370
+ interactive=False,
371
+ wrap=True,
372
+ )
373
+
374
+ # ------- COMPARE TAB -------
375
+ with gr.Tab("⚖️ Compare"):
376
+ gr.Markdown(
377
+ "### Specialist vs Generalist\n"
378
+ "*Side-by-side: Apiarist (Qwen + YOLO) vs raw generalist VLM. "
379
+ "Coming once the custom YOLO finishes training.*"
380
+ )
381
+
382
+ # ------- ABOUT TAB -------
383
+ with gr.Tab("ℹ️ About"):
384
+ gr.Markdown(
385
+ """
386
  **Apiarist** is a fully-offline vision AI for backyard beekeepers.
387
 
388
  - 🔌 No cloud APIs — runs entirely on the laptop
389
+ - 🎯 Custom-trained on labeled bee imagery
390
  - 📓 Built in 10 days for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon)
391
 
392
+ **Stack**: Qwen2.5-VL-3B + custom YOLOv8s on ZeroGPU, SQLite persistence, Gradio UI.
393
  """
394
+ )
395
+
396
+ # ---- wiring ----
397
+
398
+ analyze_btn.click(
399
+ fn=analyze_frame,
400
+ inputs=[image_input, hive_input],
401
+ outputs=[
402
+ annotated_output,
403
+ narrative_output,
404
+ json_output,
405
+ hives_table,
406
+ history_select,
407
+ ],
408
+ )
409
+
410
+ add_btn.click(
411
+ fn=add_hive_action,
412
+ inputs=[new_name, new_location, new_marker, new_notes],
413
+ outputs=[hives_table, history_select, add_msg],
414
+ )
415
+
416
+ refresh_btn.click(
417
+ fn=lambda: (
418
+ _hives_table_state(),
419
+ gr.update(choices=[h["name"] for h in db.list_hives()]),
420
+ ),
421
+ outputs=[hives_table, history_select],
422
+ )
423
+
424
+ history_select.change(
425
+ fn=view_hive_history,
426
+ inputs=[history_select],
427
+ outputs=[history_table, history_summary],
428
+ )
429
+
430
+ return app
431
+
432
+
433
+ app = build_ui()
434
 
435
 
436
  if __name__ == "__main__":
db.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite layer for hive registry + inspection history.
3
+
4
+ Storage is ephemeral on HF Spaces' free tier (container restart wipes
5
+ the filesystem) — that's fine for a hackathon demo. For real-world
6
+ deployment we'd back this with HF Hub persistence or a real DB.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import time
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator
17
+
18
+ DB_PATH = Path(__file__).parent / "apiarist.sqlite"
19
+
20
+
21
+ SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS hives (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ name TEXT NOT NULL UNIQUE,
25
+ location TEXT,
26
+ queen_marker TEXT,
27
+ notes TEXT,
28
+ created_at REAL NOT NULL
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS inspections (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ hive_id INTEGER NOT NULL,
34
+ queen_detected INTEGER,
35
+ varroa_mites_visible INTEGER,
36
+ swarm_cells_detected INTEGER,
37
+ brood_pattern TEXT,
38
+ frame_health TEXT,
39
+ notes TEXT,
40
+ raw_response TEXT,
41
+ structured_json TEXT,
42
+ created_at REAL NOT NULL,
43
+ FOREIGN KEY (hive_id) REFERENCES hives(id) ON DELETE CASCADE
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_inspections_hive ON inspections(hive_id, created_at);
47
+ """
48
+
49
+
50
+ @contextmanager
51
+ def conn() -> Iterator[sqlite3.Connection]:
52
+ c = sqlite3.connect(DB_PATH)
53
+ c.row_factory = sqlite3.Row
54
+ c.execute("PRAGMA foreign_keys = ON")
55
+ try:
56
+ yield c
57
+ c.commit()
58
+ finally:
59
+ c.close()
60
+
61
+
62
+ def init_db() -> None:
63
+ with conn() as c:
64
+ c.executescript(SCHEMA)
65
+
66
+
67
+ def add_hive(name: str, location: str = "", queen_marker: str = "", notes: str = "") -> int:
68
+ with conn() as c:
69
+ cur = c.execute(
70
+ "INSERT INTO hives (name, location, queen_marker, notes, created_at) "
71
+ "VALUES (?, ?, ?, ?, ?)",
72
+ (name, location, queen_marker, notes, time.time()),
73
+ )
74
+ return cur.lastrowid or 0
75
+
76
+
77
+ def get_or_create_hive(name: str) -> int:
78
+ """Look up a hive by name; create with defaults if missing."""
79
+ name = name.strip()
80
+ if not name:
81
+ name = "Unnamed Hive"
82
+ with conn() as c:
83
+ row = c.execute("SELECT id FROM hives WHERE name = ?", (name,)).fetchone()
84
+ if row:
85
+ return row["id"]
86
+ cur = c.execute(
87
+ "INSERT INTO hives (name, created_at) VALUES (?, ?)",
88
+ (name, time.time()),
89
+ )
90
+ return cur.lastrowid or 0
91
+
92
+
93
+ def list_hives() -> list[dict]:
94
+ with conn() as c:
95
+ rows = c.execute(
96
+ """
97
+ SELECT h.id, h.name, h.location, h.queen_marker, h.notes, h.created_at,
98
+ COUNT(i.id) AS inspection_count,
99
+ MAX(i.created_at) AS last_inspected
100
+ FROM hives h
101
+ LEFT JOIN inspections i ON i.hive_id = h.id
102
+ GROUP BY h.id
103
+ ORDER BY h.name
104
+ """
105
+ ).fetchall()
106
+ return [dict(r) for r in rows]
107
+
108
+
109
+ def delete_hive(hive_id: int) -> None:
110
+ with conn() as c:
111
+ c.execute("DELETE FROM hives WHERE id = ?", (hive_id,))
112
+
113
+
114
+ def add_inspection(hive_id: int, results: dict, raw_response: str = "") -> int:
115
+ with conn() as c:
116
+ cur = c.execute(
117
+ """
118
+ INSERT INTO inspections (
119
+ hive_id, queen_detected, varroa_mites_visible,
120
+ swarm_cells_detected, brood_pattern, frame_health,
121
+ notes, raw_response, structured_json, created_at
122
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
123
+ """,
124
+ (
125
+ hive_id,
126
+ int(bool(results.get("queen_detected"))),
127
+ int(results.get("varroa_mites_visible", 0) or 0),
128
+ int(bool(results.get("swarm_cells_detected"))),
129
+ results.get("brood_pattern", ""),
130
+ results.get("frame_health", ""),
131
+ results.get("notes", ""),
132
+ raw_response,
133
+ json.dumps(results),
134
+ time.time(),
135
+ ),
136
+ )
137
+ return cur.lastrowid or 0
138
+
139
+
140
+ def get_inspections_for_hive(hive_id: int, limit: int = 50) -> list[dict]:
141
+ with conn() as c:
142
+ rows = c.execute(
143
+ """
144
+ SELECT id, queen_detected, varroa_mites_visible, swarm_cells_detected,
145
+ brood_pattern, frame_health, notes, created_at
146
+ FROM inspections
147
+ WHERE hive_id = ?
148
+ ORDER BY created_at DESC
149
+ LIMIT ?
150
+ """,
151
+ (hive_id, limit),
152
+ ).fetchall()
153
+ return [dict(r) for r in rows]
154
+
155
+
156
+ def hive_stats() -> dict:
157
+ """High-level apiary summary for the dashboard."""
158
+ with conn() as c:
159
+ total_hives = c.execute("SELECT COUNT(*) AS n FROM hives").fetchone()["n"]
160
+ total_inspections = c.execute(
161
+ "SELECT COUNT(*) AS n FROM inspections"
162
+ ).fetchone()["n"]
163
+ recent = c.execute(
164
+ """
165
+ SELECT h.name, i.frame_health, i.varroa_mites_visible, i.created_at
166
+ FROM inspections i
167
+ JOIN hives h ON h.id = i.hive_id
168
+ ORDER BY i.created_at DESC
169
+ LIMIT 5
170
+ """
171
+ ).fetchall()
172
+ return {
173
+ "total_hives": total_hives,
174
+ "total_inspections": total_inspections,
175
+ "recent": [dict(r) for r in recent],
176
+ }
dev-requirements.txt CHANGED
@@ -1,3 +1,6 @@
1
  # Local-only deps (not installed on HF Space)
2
  requests>=2.31.0
3
  tqdm>=4.66.0
 
 
 
 
1
  # Local-only deps (not installed on HF Space)
2
  requests>=2.31.0
3
  tqdm>=4.66.0
4
+ python-dotenv>=1.0.0
5
+ roboflow>=1.1.0
6
+ ultralytics>=8.2.0
scripts/download_yolo_weights.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pull Matt Nudi's honey-bee/drone/queen YOLO weights from Roboflow once,
3
+ then copy the .pt file into weights/ so the Space can load it locally
4
+ with ultralytics — no Roboflow auth needed at runtime.
5
+
6
+ Usage:
7
+ py scripts/download_yolo_weights.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ from dotenv import load_dotenv
17
+
18
+
19
+ WORKSPACE = "matt-nudi"
20
+ PROJECT = "honey-bee-detection-model-zgjnb"
21
+ WEIGHTS_DIR = Path("weights")
22
+ TARGET_PT = WEIGHTS_DIR / "honey_bee_detector.pt"
23
+
24
+
25
+ def main() -> None:
26
+ load_dotenv()
27
+ api_key = os.environ.get("ROBOFLOW_API_KEY")
28
+ if not api_key:
29
+ raise SystemExit(
30
+ "Missing ROBOFLOW_API_KEY. Add it to .env at the project root."
31
+ )
32
+
33
+ WEIGHTS_DIR.mkdir(exist_ok=True)
34
+
35
+ print(f"Connecting to Roboflow as workspace={WORKSPACE!r} ...")
36
+ from roboflow import Roboflow
37
+
38
+ rf = Roboflow(api_key=api_key)
39
+ project = rf.workspace(WORKSPACE).project(PROJECT)
40
+
41
+ versions = project.versions()
42
+ if not versions:
43
+ raise SystemExit(f"No trained versions found for {PROJECT!r}.")
44
+
45
+ # Pick the highest version number with a trained model
46
+ versions_sorted = sorted(versions, key=lambda v: v.version, reverse=True)
47
+ print(f"Available versions: {[v.version for v in versions_sorted]}")
48
+
49
+ version_id = versions_sorted[0].version
50
+ print(f"Using latest version: v{version_id}")
51
+ version = project.version(version_id)
52
+
53
+ # Approach 1: try to grab the trained weights via the inference package.
54
+ # It downloads to ~/.cache/inference (or similar) on first use.
55
+ print("\nTriggering weight download via roboflow.inference ...")
56
+ try:
57
+ from inference import get_model
58
+
59
+ model = get_model(
60
+ model_id=f"{PROJECT}/{version_id}",
61
+ api_key=api_key,
62
+ )
63
+ # Best-effort: tell us where it landed
64
+ cache_root = Path.home() / ".inference"
65
+ pt_files = list(cache_root.rglob("*.pt"))
66
+ if not pt_files:
67
+ cache_root = Path.home() / ".cache" / "inference"
68
+ pt_files = list(cache_root.rglob("*.pt"))
69
+ if pt_files:
70
+ # Pick the most recently modified
71
+ latest = max(pt_files, key=lambda p: p.stat().st_mtime)
72
+ shutil.copy(latest, TARGET_PT)
73
+ print(f"\n✓ Copied weights from {latest}")
74
+ print(f" to {TARGET_PT.resolve()}")
75
+ print(f" size: {TARGET_PT.stat().st_size / 1024 / 1024:.1f} MB")
76
+ return
77
+ print("inference cache had no .pt files yet; falling back to dataset export.")
78
+ except Exception as e:
79
+ print(f"inference path failed: {e}\nFalling back to dataset export ...")
80
+
81
+ # Approach 2: download the dataset export, which sometimes ships weights.
82
+ download_dir = Path("data") / "raw" / f"roboflow_{PROJECT}_v{version_id}"
83
+ download_dir.parent.mkdir(parents=True, exist_ok=True)
84
+ dataset = version.download("yolov8", location=str(download_dir))
85
+ print(f"\nDataset downloaded to: {dataset.location}")
86
+ pt_files = list(Path(dataset.location).rglob("*.pt"))
87
+ if pt_files:
88
+ shutil.copy(pt_files[0], TARGET_PT)
89
+ print(f"\n✓ Copied weights to {TARGET_PT.resolve()}")
90
+ print(f" size: {TARGET_PT.stat().st_size / 1024 / 1024:.1f} MB")
91
+ return
92
+
93
+ raise SystemExit(
94
+ "\nCould not locate trained .pt weights via either path. "
95
+ "Check the project's available versions and whether the author "
96
+ "published a trained model (some only ship the dataset)."
97
+ )
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
scripts/extract_dataset.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extract Roboflow dataset zip with Windows long-path support.
3
+
4
+ Roboflow ships images with absurdly long filenames (URL slugs preserved).
5
+ Windows' default 260-char MAX_PATH limit breaks normal extraction.
6
+ We use the \\?\ prefix which opts a path into the long-path code path.
7
+
8
+ Usage:
9
+ py scripts/extract_dataset.py
10
+ """
11
+
12
+ import sys
13
+ import zipfile
14
+ from pathlib import Path
15
+
16
+
17
+ ZIP_PATH = Path("data/raw/roboflow_honey-bee-detection-model-zgjnb_v4/roboflow.zip")
18
+ DEST = Path("data/raw/roboflow_honey-bee-detection-model-zgjnb_v4").resolve()
19
+
20
+
21
+ def lp(path) -> str:
22
+ """Return a Windows long-path string (\\?\C:\...) if needed."""
23
+ s = str(path)
24
+ if sys.platform == "win32":
25
+ # \\?\ prefix MUST use absolute path with backslashes
26
+ s = s.replace("/", "\\")
27
+ if not s.startswith("\\\\?\\"):
28
+ s = "\\\\?\\" + s
29
+ return s
30
+
31
+
32
+ def main() -> None:
33
+ if not ZIP_PATH.exists():
34
+ raise SystemExit(f"Missing {ZIP_PATH}")
35
+
36
+ with zipfile.ZipFile(ZIP_PATH) as z:
37
+ members = z.namelist()
38
+ total = len(members)
39
+ print(f"Extracting {total} entries from {ZIP_PATH.name} ...")
40
+ ok = 0
41
+ fail = 0
42
+ for i, member in enumerate(members):
43
+ if i % 500 == 0:
44
+ print(f" progress: {i}/{total} (ok={ok}, fail={fail})")
45
+ target = DEST / member
46
+ try:
47
+ target.parent.mkdir(parents=True, exist_ok=True)
48
+ except Exception:
49
+ pass
50
+ if member.endswith("/"):
51
+ continue
52
+ try:
53
+ with z.open(member) as src:
54
+ data = src.read()
55
+ with open(lp(target), "wb") as dst:
56
+ dst.write(data)
57
+ ok += 1
58
+ except Exception as e:
59
+ fail += 1
60
+ if fail <= 5:
61
+ print(f" [!] {member[:80]}... -> {type(e).__name__}: {e}")
62
+
63
+ print(f"\nDone. ok={ok}, fail={fail}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
scripts/train_yolo_on_modal.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train YOLOv8s on Matt Nudi's bee/drone/queen dataset using Modal GPU.
3
+
4
+ The Modal container downloads the dataset itself via Roboflow (we pass
5
+ the API key from the local .env), trains for 50 epochs, and persists
6
+ the best.pt weights to a Modal Volume.
7
+
8
+ To run:
9
+ py scripts/train_yolo_on_modal.py
10
+
11
+ After training, download the weights with:
12
+ modal volume get apiarist-weights /apiarist/weights/best.pt weights/honey_bee_detector.pt
13
+ """
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import modal
19
+
20
+
21
+ APP_NAME = "apiarist-yolo-train"
22
+ VOLUME_NAME = "apiarist-weights"
23
+ EPOCHS = 50
24
+ IMG_SIZE = 640
25
+ BATCH = 16
26
+ BASE_WEIGHTS = "yolov8s.pt"
27
+
28
+ image = (
29
+ modal.Image.debian_slim(python_version="3.11")
30
+ .pip_install(
31
+ "ultralytics==8.3.81",
32
+ "roboflow==1.1.50",
33
+ "pyyaml",
34
+ )
35
+ .apt_install("libgl1", "libglib2.0-0")
36
+ )
37
+
38
+ vol = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
39
+
40
+ app = modal.App(APP_NAME)
41
+
42
+
43
+ @app.function(
44
+ image=image,
45
+ gpu="T4",
46
+ volumes={"/weights": vol},
47
+ timeout=3 * 60 * 60,
48
+ )
49
+ def train(rf_api_key: str) -> str:
50
+ import shutil
51
+ import sys
52
+ from pathlib import Path
53
+
54
+ from roboflow import Roboflow
55
+ from ultralytics import YOLO
56
+
57
+ print("=" * 60)
58
+ print("Downloading Matt Nudi bee/queen/drone dataset from Roboflow ...")
59
+ print("=" * 60)
60
+ rf = Roboflow(api_key=rf_api_key)
61
+ project = rf.workspace("matt-nudi").project(
62
+ "honey-bee-detection-model-zgjnb"
63
+ )
64
+ version = project.version(4)
65
+ dataset = version.download("yolov8", location="/tmp/dataset")
66
+ print(f"Dataset ready at {dataset.location}")
67
+
68
+ print("\n" + "=" * 60)
69
+ print(f"Training YOLOv8s for {EPOCHS} epochs on T4 ...")
70
+ print("=" * 60)
71
+ model = YOLO(BASE_WEIGHTS)
72
+ results = model.train(
73
+ data=f"{dataset.location}/data.yaml",
74
+ epochs=EPOCHS,
75
+ imgsz=IMG_SIZE,
76
+ batch=BATCH,
77
+ project="/weights",
78
+ name="apiarist",
79
+ exist_ok=True,
80
+ device=0,
81
+ patience=15,
82
+ )
83
+
84
+ best_pt = Path("/weights/apiarist/weights/best.pt")
85
+ if not best_pt.exists():
86
+ print("ERROR: best.pt not found after training", file=sys.stderr)
87
+ sys.exit(1)
88
+
89
+ size_mb = best_pt.stat().st_size / 1024 / 1024
90
+ print(f"\n[OK] best.pt saved at {best_pt} ({size_mb:.1f} MB)")
91
+
92
+ vol.commit()
93
+ return str(best_pt)
94
+
95
+
96
+ @app.local_entrypoint()
97
+ def main() -> None:
98
+ from dotenv import load_dotenv
99
+
100
+ load_dotenv()
101
+ api_key = os.environ.get("ROBOFLOW_API_KEY")
102
+ if not api_key:
103
+ raise SystemExit(
104
+ "Missing ROBOFLOW_API_KEY in .env. Add it before running."
105
+ )
106
+
107
+ print("Kicking off Modal training (this takes ~30-45 min on T4) ...")
108
+ weights_path = train.remote(rf_api_key=api_key)
109
+ print("\n" + "=" * 60)
110
+ print(f"DONE. Weights at: {weights_path}")
111
+ print("=" * 60)
112
+ print(
113
+ "\nDownload locally with:\n"
114
+ f" modal volume get {VOLUME_NAME} /apiarist/weights/best.pt "
115
+ f"weights/honey_bee_detector.pt"
116
+ )