Apiarist Dev
feat: test-time augmentation (4 views) + size filter (top 30% bbox area) for queen detection
85cec4b | """ | |
| 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 | |