Apiarist / queen_clf.py
Apiarist Dev
feat: test-time augmentation (4 views) + size filter (top 30% bbox area) for queen detection
85cec4b
Raw
History Blame Contribute Delete
4.4 kB
"""
Binary queen-vs-worker classifier inference.
A dedicated EfficientNet-B0 binary classifier trained on cropped bee
images. Given a cropped bee, returns a probability that it is a queen.
This is much more focused than asking a multi-class YOLO to classify
queens (where localization + classification compete), or a VLM cascade
(where the generalist model has no bee-specific training).
Usage:
import queen_clf
if queen_clf.is_available():
results = queen_clf.classify_crops([crop1, crop2, ...])
# results = [{'queen_prob': 0.92, 'is_queen': True}, ...]
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PIL import Image
_HERE = Path(os.path.dirname(os.path.abspath(__file__)))
WEIGHTS_PATH = _HERE / "weights" / "queen_classifier.pt"
# Confidence threshold for calling a crop a queen. Tuned empirically -
# if false positives happen on the live Space, raise this.
# Set conservatively to avoid false queens on borderline crops.
QUEEN_PROB_THRESHOLD = 0.92
# At most one queen per frame (real frames almost never have more).
MAX_QUEENS_PER_FRAME = 1
_model = None
_meta = None
_tf = None
_failed = False
_logged = False
def is_available() -> bool:
return WEIGHTS_PATH.exists() and WEIGHTS_PATH.stat().st_size > 1024 and not _failed
def _log_once():
global _logged
if _logged:
return
_logged = True
print(f"[queen_clf] weights path: {WEIGHTS_PATH}", file=sys.stderr)
print(f"[queen_clf] weights exist: {WEIGHTS_PATH.exists()}", file=sys.stderr)
if WEIGHTS_PATH.exists():
size = WEIGHTS_PATH.stat().st_size
print(f"[queen_clf] weights size: {size} bytes ({size/1024/1024:.1f} MB)",
file=sys.stderr)
def _load():
global _model, _meta, _tf, _failed
_log_once()
if _model is not None or _failed:
return _model
if not WEIGHTS_PATH.exists():
_failed = True
return None
try:
import torch
import timm
from torchvision import transforms
ckpt = torch.load(str(WEIGHTS_PATH), map_location="cpu", weights_only=False)
arch = ckpt.get("arch", "efficientnet_b0")
img_size = ckpt.get("img_size", 224)
class_to_idx = ckpt.get("class_to_idx", {"queen": 0, "worker": 1})
model = timm.create_model(arch, pretrained=False, num_classes=2)
model.load_state_dict(ckpt["state_dict"])
model.eval()
tf = transforms.Compose([
transforms.Resize((img_size, img_size)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406],
[0.229, 0.224, 0.225]),
])
_model = model
_meta = {"class_to_idx": class_to_idx, "img_size": img_size, "arch": arch,
"queen_idx": class_to_idx.get("queen", 0)}
_tf = tf
print(f"[queen_clf] loaded {arch}, classes={class_to_idx}", file=sys.stderr)
except Exception as e:
print(f"[queen_clf] load failed: {type(e).__name__}: {e}", file=sys.stderr)
_failed = True
return _model
def classify_crops(crops: list[Image.Image]) -> list[dict]:
"""Given a list of PIL crops, return per-crop queen probabilities
averaged across 4 test-time augmentations (original, h-flip, v-flip,
180-rotation). False positives often score high on one orientation
but collapse under augmentation; true queens stay high across all."""
model = _load()
if model is None or not crops:
return [{"queen_prob": 0.0, "is_queen": False} for _ in crops]
import torch
queen_idx = _meta["queen_idx"]
per_crop_probs = []
for c in crops:
c = c.convert("RGB")
# Build 4 augmented views
views = [
c,
c.transpose(Image.FLIP_LEFT_RIGHT),
c.transpose(Image.FLIP_TOP_BOTTOM),
c.transpose(Image.ROTATE_180),
]
batch = torch.stack([_tf(v) for v in views])
with torch.no_grad():
probs = torch.softmax(model(batch), dim=1)
# Average queen probability across the 4 views
avg_qp = float(probs[:, queen_idx].mean().item())
per_crop_probs.append(avg_qp)
out = []
for qp in per_crop_probs:
out.append({
"queen_prob": round(qp, 3),
"is_queen": qp >= QUEEN_PROB_THRESHOLD,
})
return out