Apiarist Dev commited on
Commit
be206e5
·
1 Parent(s): 34483f7

feat: inspection dashboard (verdict + stat tiles), colony trend charts, polished A/B kill-shot cards

Browse files
Files changed (2) hide show
  1. app.py +200 -55
  2. db.py +1 -1
app.py CHANGED
@@ -99,6 +99,57 @@ def parse_response(text: str, hive_name: str) -> dict:
99
  }
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def build_narrative(r: dict, raw: str) -> str:
103
  top_probs = r.get("cascade_top_probs", [])
104
  if r["queen_detected"]:
@@ -201,7 +252,7 @@ def _qwen_only(image: Image.Image, prompt_text: str) -> str:
201
 
202
  def analyze_frame(image: Image.Image, hive_name: str):
203
  if image is None:
204
- return None, "Upload a frame photo first.", "", _hives_table_state(), gr.update(), gr.update()
205
 
206
  # Step 1: YOLO on the MAIN container (file access + log visibility).
207
  detections, _ = detector.detect(image)
@@ -245,6 +296,7 @@ def analyze_frame(image: Image.Image, hive_name: str):
245
  except Exception as e:
246
  return (
247
  display_image,
 
248
  f"Model inference failed: {type(e).__name__}: {e}",
249
  "",
250
  _hives_table_state(),
@@ -285,6 +337,7 @@ def analyze_frame(image: Image.Image, hive_name: str):
285
  results["detector_used"] = False
286
 
287
  narrative = build_narrative(results, response)
 
288
 
289
  # Persist the inspection
290
  hive_id = db.get_or_create_hive(results["hive"])
@@ -293,6 +346,7 @@ def analyze_frame(image: Image.Image, hive_name: str):
293
  hive_names = [h["name"] for h in db.list_hives()]
294
  return (
295
  display_image,
 
296
  narrative,
297
  json.dumps(results, indent=2),
298
  _hives_table_state(),
@@ -320,64 +374,82 @@ def run_comparison(image: Image.Image):
320
  counts = detector.summarize_counts(detections)
321
  yolo_active = detector.is_available()
322
 
 
 
 
 
 
 
 
 
 
323
  if yolo_active:
324
- if counts:
325
- prefix = (
326
- "A specialist detector has already analyzed this image and found:\n"
327
- + "\n".join(f"- {n} {cls}" for cls, n in counts.items())
328
- + "\n\nIncorporate these counts into your assessment.\n\n"
329
- )
330
- else:
331
- prefix = (
332
- "A specialist detector analyzed this image and found nothing "
333
- "definitive. Be conservative.\n\n"
334
- )
335
  left_response = _run_qwen(image, prefix=prefix)
336
  left_results = parse_response(left_response, "")
337
  left_results["queen_detected"] = counts.get("queen", 0) > 0
338
  left_results["yolo_counts"] = counts
339
  left_results["detector_used"] = True
340
- left_md = _short_summary(" Apiarist", left_results, extra_counts=counts)
341
  left_image = annotated if annotated is not None else image
342
  else:
343
- left_md = (
344
- "### Apiarist\n\n"
345
- "*Custom YOLO weights haven't landed yet, "
346
- "left column will activate as soon as `weights/honey_bee_detector.pt` is committed.*"
347
- )
348
  left_image = image
349
 
350
  # ----- RIGHT: Qwen alone (generalist, no specialist context) -----
351
  right_response = _run_qwen(image, prefix="")
352
- right_results = parse_response(right_response, "")
353
- right_results["detector_used"] = False
354
- right_md = _short_summary(" Qwen alone", right_results)
355
 
356
- return left_image, left_md, image, right_md
357
 
358
 
359
- def _short_summary(title: str, r: dict, extra_counts: dict | None = None) -> str:
360
- queen = " Queen detected" if r["queen_detected"] else " No queen visible"
361
- mite_icon = "" if r["varroa_mites_visible"] >= 3 else ""
362
- swarm = (
363
- " Swarm cells" if r["swarm_cells_detected"] else " No swarm cells"
 
 
 
364
  )
365
- counts_block = ""
366
- if extra_counts:
367
- counts_block = (
368
- "\n**Specialist counts:** "
369
- + ", ".join(f"{n} {cls}" for cls, n in extra_counts.items())
370
- )
371
- return f"""### {title}
372
-
373
- {queen}
374
- {mite_icon} {r['varroa_mites_visible']} varroa mite(s)
375
- {swarm}
376
- {counts_block}
377
-
378
- **Brood:** {r['brood_pattern']} **Health:** {r['frame_health']}
379
- **Notes:** {r['notes']}
380
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
 
383
  # ---------------------------------------------------------------- Hives tab helpers
@@ -427,15 +499,21 @@ def add_hive_action(name, location, marker, notes):
427
  )
428
 
429
 
 
 
 
 
 
430
  def view_hive_history(hive_name):
 
431
  if not hive_name:
432
- return [], "_Pick a hive above to see its inspection history._"
433
  hive = next((h for h in db.list_hives() if h["name"] == hive_name), None)
434
  if not hive:
435
- return [], "_Hive not found._"
436
  inspections = db.get_inspections_for_hive(hive["id"])
437
  if not inspections:
438
- return [], f"_No inspections recorded for **{hive_name}** yet._"
439
  rows = []
440
  for i in inspections:
441
  rows.append(
@@ -450,11 +528,26 @@ def view_hive_history(hive_name):
450
  )
451
  summary = (
452
  f"### {hive_name}\n"
453
- f"**Total inspections:** {len(inspections)}\n"
454
  f"**Last inspected:** "
455
  f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(inspections[0]['created_at']))}"
456
  )
457
- return rows, summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
 
460
  # ---------------------------------------------------------------- UI
@@ -556,6 +649,50 @@ table { border-color: rgba(244,163,0,0.25) !important; }
556
  .sw-drone { background: #ff5050; }
557
  .sw-mite { background: #dc32dc; }
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  /* ----- Footer ----- */
560
  #apiarist-footer {
561
  margin-top: 22px; padding-top: 14px;
@@ -677,7 +814,9 @@ def build_ui() -> gr.Blocks:
677
  </div>
678
  """
679
  )
680
- narrative_output = gr.Markdown()
 
 
681
  with gr.Accordion("Raw JSON", open=False):
682
  json_output = gr.Code(language="json")
683
 
@@ -724,6 +863,13 @@ def build_ui() -> gr.Blocks:
724
  choices=[h["name"] for h in db.list_hives()],
725
  )
726
  history_summary = gr.Markdown()
 
 
 
 
 
 
 
727
  history_table = gr.Dataframe(
728
  headers=[
729
  "When", "Queen?", "Mites", "Swarm?",
@@ -764,15 +910,13 @@ def build_ui() -> gr.Blocks:
764
 
765
  with gr.Row():
766
  with gr.Column():
767
- gr.Markdown("### Apiarist (specialist + generalist)")
768
  cmp_left_image = gr.Image(
769
- label="Annotated by YOLO + Qwen"
770
  )
771
- cmp_left_text = gr.Markdown()
772
  with gr.Column():
773
- gr.Markdown("### Raw generalist (Qwen alone)")
774
  cmp_right_image = gr.Image(label="No annotations")
775
- cmp_right_text = gr.Markdown()
776
 
777
  # ------- ABOUT TAB -------
778
  with gr.Tab("About"):
@@ -844,6 +988,7 @@ Off the Grid - Well-Tuned - Off-Brand - Sharing is Caring
844
  inputs=[image_input, hive_input],
845
  outputs=[
846
  annotated_output,
 
847
  narrative_output,
848
  json_output,
849
  hives_table,
@@ -880,7 +1025,7 @@ Off the Grid - Well-Tuned - Off-Brand - Sharing is Caring
880
  history_select.change(
881
  fn=view_hive_history,
882
  inputs=[history_select],
883
- outputs=[history_table, history_summary],
884
  )
885
 
886
  def _build_report():
 
99
  }
100
 
101
 
102
+ def build_dashboard(r: dict) -> str:
103
+ """Render a polished inspection 'report card' as HTML: a health-verdict
104
+ banner plus color-coded stat tiles. This is the visual hero of a result."""
105
+ counts = r.get("yolo_counts") or {}
106
+ top_probs = r.get("cascade_top_probs", [])
107
+ queen_conf = int(top_probs[0] * 100) if top_probs else 0
108
+
109
+ n_bees = counts.get("bee", 0)
110
+ n_drones = counts.get("drone", 0)
111
+ n_mites = counts.get("varroa", 0)
112
+ queen = r.get("queen_detected", False)
113
+
114
+ # Verdict logic: mites drive alarm, missing queen drives watch.
115
+ if n_mites >= 3:
116
+ verdict, vcolor, vsub = "ALERT", "#e3493b", f"{n_mites} varroa mites detected - treat soon"
117
+ elif n_mites >= 1:
118
+ verdict, vcolor, vsub = "WATCH", "#e8a317", f"{n_mites} varroa mite(s) - monitor"
119
+ elif not queen and r.get("detector_used"):
120
+ verdict, vcolor, vsub = "WATCH", "#e8a317", "No queen confirmed on this frame"
121
+ elif r.get("detector_used"):
122
+ verdict, vcolor, vsub = "HEALTHY", "#2faa55", "Queen present, no mites detected"
123
+ else:
124
+ verdict, vcolor, vsub = "ANALYZED", "#8a8a8a", "Narrative-only (detector offline)"
125
+
126
+ def tile(label, value, sub, color):
127
+ return f"""
128
+ <div class="stat-tile" style="border-top:3px solid {color};">
129
+ <div class="stat-val" style="color:{color};">{value}</div>
130
+ <div class="stat-label">{label}</div>
131
+ <div class="stat-sub">{sub}</div>
132
+ </div>"""
133
+
134
+ queen_val = "YES" if queen else "-"
135
+ queen_sub = f"{queen_conf}% confidence" if queen else "not found"
136
+
137
+ return f"""
138
+ <div id="dash">
139
+ <div class="verdict" style="background:linear-gradient(90deg,{vcolor}33,transparent);border-left:5px solid {vcolor};">
140
+ <div class="verdict-hive">{r.get('hive','Unnamed Hive')}</div>
141
+ <div class="verdict-main" style="color:{vcolor};">{verdict}</div>
142
+ <div class="verdict-sub">{vsub}</div>
143
+ </div>
144
+ <div class="stat-row">
145
+ {tile("QUEEN", queen_val, queen_sub, "#32c864" if queen else "#8a8a8a")}
146
+ {tile("WORKERS", n_bees, "detected", "#f4a300")}
147
+ {tile("DRONES", n_drones, "detected", "#ff7d4d")}
148
+ {tile("MITES", n_mites, "specialist", "#dc46dc" if n_mites else "#5f8a5f")}
149
+ </div>
150
+ </div>"""
151
+
152
+
153
  def build_narrative(r: dict, raw: str) -> str:
154
  top_probs = r.get("cascade_top_probs", [])
155
  if r["queen_detected"]:
 
252
 
253
  def analyze_frame(image: Image.Image, hive_name: str):
254
  if image is None:
255
+ return None, "", "Upload a frame photo first.", "", _hives_table_state(), gr.update(), gr.update()
256
 
257
  # Step 1: YOLO on the MAIN container (file access + log visibility).
258
  detections, _ = detector.detect(image)
 
296
  except Exception as e:
297
  return (
298
  display_image,
299
+ "",
300
  f"Model inference failed: {type(e).__name__}: {e}",
301
  "",
302
  _hives_table_state(),
 
337
  results["detector_used"] = False
338
 
339
  narrative = build_narrative(results, response)
340
+ dashboard = build_dashboard(results)
341
 
342
  # Persist the inspection
343
  hive_id = db.get_or_create_hive(results["hive"])
 
346
  hive_names = [h["name"] for h in db.list_hives()]
347
  return (
348
  display_image,
349
+ dashboard,
350
  narrative,
351
  json.dumps(results, indent=2),
352
  _hives_table_state(),
 
374
  counts = detector.summarize_counts(detections)
375
  yolo_active = detector.is_available()
376
 
377
+ # Run the queen cascade so the left side shows the confirmed queen.
378
+ if yolo_active and detections:
379
+ try:
380
+ detections, _ = cascade.verify_queens(image, detections, _qwen_only)
381
+ except Exception:
382
+ pass
383
+ annotated = detector.draw_annotations(image, detections)
384
+ counts = detector.summarize_counts(detections)
385
+
386
  if yolo_active:
387
+ prefix = (
388
+ "A specialist detector found: "
389
+ + ", ".join(f"{n} {cls}" for cls, n in counts.items())
390
+ + ". Incorporate these counts.\n\n"
391
+ ) if counts else ""
 
 
 
 
 
 
392
  left_response = _run_qwen(image, prefix=prefix)
393
  left_results = parse_response(left_response, "")
394
  left_results["queen_detected"] = counts.get("queen", 0) > 0
395
  left_results["yolo_counts"] = counts
396
  left_results["detector_used"] = True
397
+ left_html = _cmp_apiarist_card(left_results, counts)
398
  left_image = annotated if annotated is not None else image
399
  else:
400
+ left_html = "<div class='cmp-card cmp-apiarist'><h3>Apiarist</h3><p>Detector offline.</p></div>"
 
 
 
 
401
  left_image = image
402
 
403
  # ----- RIGHT: Qwen alone (generalist, no specialist context) -----
404
  right_response = _run_qwen(image, prefix="")
405
+ right_html = _cmp_raw_card(right_response)
 
 
406
 
407
+ return left_image, left_html, image, right_html
408
 
409
 
410
+ def _cmp_apiarist_card(r: dict, counts: dict) -> str:
411
+ q = counts.get("queen", 0)
412
+ rows = "".join(
413
+ f"<tr><td>{cls.capitalize()}</td><td style='text-align:right;font-weight:700;color:#ffd066'>{n}</td></tr>"
414
+ for cls, n in (("queen", counts.get("queen", 0)),
415
+ ("bee", counts.get("bee", 0)),
416
+ ("drone", counts.get("drone", 0)),
417
+ ("varroa", counts.get("varroa", 0)))
418
  )
419
+ queen_badge = (
420
+ "<span style='color:#32ff64;font-weight:700'>QUEEN CONFIRMED</span>"
421
+ if q else "<span style='color:#c9b285'>no queen this frame</span>"
422
+ )
423
+ return f"""
424
+ <div class="cmp-card cmp-apiarist">
425
+ <h3 style="color:#32ff64;">Apiarist (specialist + generalist)</h3>
426
+ <p style="margin:2px 0 8px 0;">{queen_badge}</p>
427
+ <table style="width:100%;border-collapse:collapse;font-size:0.9rem;">
428
+ <thead><tr><th style="text-align:left;color:#c9b285">Class</th>
429
+ <th style="text-align:right;color:#c9b285">Exact count</th></tr></thead>
430
+ <tbody>{rows}</tbody>
431
+ </table>
432
+ <p style="margin-top:10px;font-size:0.84rem;color:#d8c298;">
433
+ <b>Report:</b> {r.get('notes','')}</p>
434
+ <p style="font-size:0.74rem;color:#8fae8f;margin-top:6px;">
435
+ Grounded in pixel-level detections from a custom-trained model.</p>
436
+ </div>"""
437
+
438
+
439
+ def _cmp_raw_card(response: str) -> str:
440
+ text = (response or "").strip()
441
+ # Show the generalist's raw prose - typically vague, no hard counts.
442
+ if len(text) > 420:
443
+ text = text[:420] + "..."
444
+ return f"""
445
+ <div class="cmp-card cmp-raw">
446
+ <h3 style="color:#bbb;">Raw generalist VLM (no specialist)</h3>
447
+ <p style="margin:2px 0 8px 0;color:#999;">prose only - no reliable counts</p>
448
+ <p style="font-size:0.86rem;color:#cfcfcf;white-space:pre-wrap;">{text}</p>
449
+ <p style="font-size:0.74rem;color:#9a7d7d;margin-top:8px;">
450
+ A generalist describes the scene but hallucinates or omits exact
451
+ queen / mite / drone counts.</p>
452
+ </div>"""
453
 
454
 
455
  # ---------------------------------------------------------------- Hives tab helpers
 
499
  )
500
 
501
 
502
+ def _empty_trend():
503
+ import pandas as pd
504
+ return pd.DataFrame({"inspection": [], "count": [], "metric": []})
505
+
506
+
507
  def view_hive_history(hive_name):
508
+ import pandas as pd
509
  if not hive_name:
510
+ return [], "_Pick a hive above to see its inspection history._", _empty_trend()
511
  hive = next((h for h in db.list_hives() if h["name"] == hive_name), None)
512
  if not hive:
513
+ return [], "_Hive not found._", _empty_trend()
514
  inspections = db.get_inspections_for_hive(hive["id"])
515
  if not inspections:
516
+ return [], f"_No inspections recorded for **{hive_name}** yet._", _empty_trend()
517
  rows = []
518
  for i in inspections:
519
  rows.append(
 
528
  )
529
  summary = (
530
  f"### {hive_name}\n"
531
+ f"**Total inspections:** {len(inspections)} | "
532
  f"**Last inspected:** "
533
  f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(inspections[0]['created_at']))}"
534
  )
535
+
536
+ # Build a long-format trend frame (oldest -> newest) for the LinePlot.
537
+ chrono = list(reversed(inspections))
538
+ trend_rows = []
539
+ for n, i in enumerate(chrono, start=1):
540
+ mites = int(i["varroa_mites_visible"] or 0)
541
+ # bee count isn't stored as a column; derive from structured_json if present
542
+ bees = 0
543
+ try:
544
+ sj = json.loads(i.get("structured_json") or "{}")
545
+ bees = int((sj.get("yolo_counts") or {}).get("bee", 0))
546
+ except Exception:
547
+ bees = 0
548
+ trend_rows.append({"inspection": n, "count": bees, "metric": "bees"})
549
+ trend_rows.append({"inspection": n, "count": mites, "metric": "varroa mites"})
550
+ return rows, summary, pd.DataFrame(trend_rows)
551
 
552
 
553
  # ---------------------------------------------------------------- UI
 
649
  .sw-drone { background: #ff5050; }
650
  .sw-mite { background: #dc32dc; }
651
 
652
+ /* ----- Inspection dashboard ----- */
653
+ #dash { margin: 6px 0 4px 0; }
654
+ #dash .verdict {
655
+ border-radius: 12px; padding: 12px 16px; margin-bottom: 12px;
656
+ }
657
+ #dash .verdict-hive {
658
+ font-size: 0.8rem; color: #c9b285; letter-spacing: 0.04em;
659
+ text-transform: uppercase;
660
+ }
661
+ #dash .verdict-main {
662
+ font-family: 'Fraunces', Georgia, serif; font-weight: 800;
663
+ font-size: 1.7rem; line-height: 1.1;
664
+ }
665
+ #dash .verdict-sub { font-size: 0.86rem; color: #d8c298; margin-top: 2px; }
666
+ #dash .stat-row {
667
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
668
+ }
669
+ #dash .stat-tile {
670
+ background: rgba(20,15,10,0.55); border-radius: 10px;
671
+ padding: 12px 8px 10px 8px; text-align: center;
672
+ border: 1px solid rgba(244,163,0,0.15);
673
+ }
674
+ #dash .stat-val {
675
+ font-family: 'Fraunces', Georgia, serif; font-weight: 800;
676
+ font-size: 1.7rem; line-height: 1;
677
+ }
678
+ #dash .stat-label {
679
+ font-size: 0.72rem; letter-spacing: 0.08em;
680
+ color: #c9b285; margin-top: 4px; text-transform: uppercase;
681
+ }
682
+ #dash .stat-sub { font-size: 0.68rem; color: #9c8662; margin-top: 1px; }
683
+
684
+ /* ----- A/B compare cards ----- */
685
+ .cmp-card { border-radius: 12px; padding: 14px 16px; height: 100%; }
686
+ .cmp-apiarist {
687
+ border: 1px solid rgba(50,200,100,0.45);
688
+ background: linear-gradient(160deg, rgba(50,200,100,0.10), transparent);
689
+ }
690
+ .cmp-raw {
691
+ border: 1px dashed rgba(150,150,150,0.45);
692
+ background: rgba(40,40,40,0.25);
693
+ }
694
+ .cmp-card h3 { margin-top: 0 !important; }
695
+
696
  /* ----- Footer ----- */
697
  #apiarist-footer {
698
  margin-top: 22px; padding-top: 14px;
 
814
  </div>
815
  """
816
  )
817
+ dashboard_output = gr.HTML()
818
+ with gr.Accordion("Full report + raw model output", open=False):
819
+ narrative_output = gr.Markdown()
820
  with gr.Accordion("Raw JSON", open=False):
821
  json_output = gr.Code(language="json")
822
 
 
863
  choices=[h["name"] for h in db.list_hives()],
864
  )
865
  history_summary = gr.Markdown()
866
+ history_trend = gr.LinePlot(
867
+ x="inspection",
868
+ y="count",
869
+ color="metric",
870
+ title="Colony trend (bees & varroa mites over inspections)",
871
+ height=240,
872
+ )
873
  history_table = gr.Dataframe(
874
  headers=[
875
  "When", "Queen?", "Mites", "Swarm?",
 
910
 
911
  with gr.Row():
912
  with gr.Column():
 
913
  cmp_left_image = gr.Image(
914
+ label="Annotated by YOLO + classifier"
915
  )
916
+ cmp_left_text = gr.HTML()
917
  with gr.Column():
 
918
  cmp_right_image = gr.Image(label="No annotations")
919
+ cmp_right_text = gr.HTML()
920
 
921
  # ------- ABOUT TAB -------
922
  with gr.Tab("About"):
 
988
  inputs=[image_input, hive_input],
989
  outputs=[
990
  annotated_output,
991
+ dashboard_output,
992
  narrative_output,
993
  json_output,
994
  hives_table,
 
1025
  history_select.change(
1026
  fn=view_hive_history,
1027
  inputs=[history_select],
1028
+ outputs=[history_table, history_summary, history_trend],
1029
  )
1030
 
1031
  def _build_report():
db.py CHANGED
@@ -142,7 +142,7 @@ def get_inspections_for_hive(hive_id: int, limit: int = 50) -> list[dict]:
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
 
142
  rows = c.execute(
143
  """
144
  SELECT id, queen_detected, varroa_mites_visible, swarm_cells_detected,
145
+ brood_pattern, frame_health, notes, created_at, structured_json
146
  FROM inspections
147
  WHERE hive_id = ?
148
  ORDER BY created_at DESC