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
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 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 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 |
-
|
| 341 |
left_image = annotated if annotated is not None else image
|
| 342 |
else:
|
| 343 |
-
|
| 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 |
-
|
| 353 |
-
right_results["detector_used"] = False
|
| 354 |
-
right_md = _short_summary(" Qwen alone", right_results)
|
| 355 |
|
| 356 |
-
return left_image,
|
| 357 |
|
| 358 |
|
| 359 |
-
def
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
| 364 |
)
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
{
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 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)}
|
| 454 |
f"**Last inspected:** "
|
| 455 |
f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(inspections[0]['created_at']))}"
|
| 456 |
)
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 +
|
| 770 |
)
|
| 771 |
-
cmp_left_text = gr.
|
| 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.
|
| 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
|