| |
| """FelineBCS inference: frozen CLIP ViT-B/32 backbone + trained heads. |
| Predicts Body Condition Score (1-9) with an uncertainty-based reject option. |
| |
| NON-DIAGNOSTIC: weak-label model trained on vision-LLM scores. Not veterinary advice. |
| """ |
| import os |
|
|
| import numpy as np |
| os.environ.setdefault("HF_HUB_DISABLE_XET", "1") |
| import torch, joblib, open_clip |
| import torchvision.transforms as T |
| from PIL import Image |
|
|
| MODEL_DIR_ENV = "FELINEBCS_MODEL_DIR" |
| DEFAULT_MODEL_DIR = "models" |
| REQUIRED_MODEL_FILES = ( |
| "clip_vitb32_regressor.joblib", |
| "clip_vitb32_clf9.joblib", |
| "clip_vitb32_clf4.joblib", |
| ) |
| _MEAN = (0.48145466, 0.4578275, 0.40821073) |
| _STD = (0.26862954, 0.26130258, 0.27577711) |
| GRP_NAMES = {0: "Underweight (BCS 1-3)", 1: "Ideal (BCS 4-5)", |
| 2: "Overweight (BCS 6-7)", 3: "Obese (BCS 8-9)"} |
| |
| |
| DISAGREE_REJECT = 1.5 |
|
|
|
|
| class MissingModelArtifactsError(RuntimeError): |
| """Raised when the trained head artifacts are unavailable.""" |
|
|
|
|
| def resolve_model_dir(model_dir=None): |
| return os.path.expanduser(model_dir or os.environ.get(MODEL_DIR_ENV, DEFAULT_MODEL_DIR)) |
|
|
|
|
| def validate_model_dir(model_dir): |
| missing = [ |
| os.path.join(model_dir, name) |
| for name in REQUIRED_MODEL_FILES |
| if not os.path.exists(os.path.join(model_dir, name)) |
| ] |
| if missing: |
| missing_list = "\n".join(f"- {path}" for path in missing) |
| raise MissingModelArtifactsError( |
| "FelineBCS model artifacts were not found.\n" |
| f"Set {MODEL_DIR_ENV} to a directory containing the trained heads, " |
| f"or place them in ./{DEFAULT_MODEL_DIR}/.\n" |
| "Required files:\n" |
| f"{missing_list}" |
| ) |
|
|
| def _bcs_to_grp(b): |
| b = int(b) |
| if b <= 3: return 0 |
| if b <= 5: return 1 |
| if b <= 7: return 2 |
| return 3 |
|
|
| class FelineBCS: |
| def __init__(self, model_dir=None, device=None): |
| model_dir = resolve_model_dir(model_dir) |
| validate_model_dir(model_dir) |
| self.model_dir = model_dir |
| if device is None: |
| device = os.environ.get("FELINEBCS_DEVICE", "cpu") |
| self.device = device |
| model, _, self.preprocess = open_clip.create_model_and_transforms( |
| "ViT-B-32", pretrained="openai") |
| self.visual = model.visual.eval().to(device) |
| self.reg = joblib.load(os.path.join(model_dir, "clip_vitb32_regressor.joblib")) |
| self.clf9 = joblib.load(os.path.join(model_dir, "clip_vitb32_clf9.joblib")) |
| self.clf4 = joblib.load(os.path.join(model_dir, "clip_vitb32_clf4.joblib")) |
| self._tta = [self.preprocess, |
| self._mk(True, 1.0), self._mk(False, 1.1), |
| self._mk(True, 1.1), self._mk(False, 1.2)] |
|
|
| @staticmethod |
| def _mk(hflip, scale): |
| ops = ([T.RandomHorizontalFlip(1.0)] if hflip else []) + \ |
| [T.Resize(int(224*scale)), T.CenterCrop(224), |
| T.ToTensor(), T.Normalize(_MEAN, _STD)] |
| return T.Compose(ops) |
|
|
| def _feat(self, img, tf): |
| with torch.no_grad(): |
| return self.visual(tf(img).unsqueeze(0).to(self.device)).cpu().numpy()[0] |
|
|
| def predict(self, img): |
| if isinstance(img, str): |
| img = Image.open(img) |
| img = img.convert("RGB") |
| f = self._feat(img, self.preprocess).reshape(1, -1) |
| bcs = float(np.clip(self.reg.predict(f)[0], 1, 9)) |
| p9 = self.clf9.predict_proba(f)[0] |
| ev = float((p9 * self.clf9.classes_).sum()) |
| p4 = self.clf4.predict_proba(f)[0] |
| grp_clf = int(self.clf4.classes_[p4.argmax()]) |
| |
| |
| |
| grp = _bcs_to_grp(round(bcs)) |
| |
| tta = np.array([np.clip(self.reg.predict(self._feat(img, tf).reshape(1, -1))[0], 1, 9) |
| for tf in self._tta]) |
| disagree = abs(bcs - ev) |
| return { |
| "bcs": round(bcs, 1), |
| "bcs_rounded": int(round(bcs)), |
| "group": GRP_NAMES[grp], |
| "group_clf": GRP_NAMES[grp_clf], |
| "group_clf_conf": round(float(p4.max()), 3), |
| "group_agree": bool(grp == grp_clf), |
| "classifier_expected": round(ev, 1), |
| "tta_std": round(float(tta.std()), 2), |
| "disagreement": round(disagree, 2), |
| "reject": bool(disagree > DISAGREE_REJECT), |
| "prob9": {int(c): round(float(p), 3) for c, p in zip(self.clf9.classes_, p9)}, |
| } |
|
|
| if __name__ == "__main__": |
| import sys, json |
| m = FelineBCS() |
| print(json.dumps(m.predict(sys.argv[1]), indent=2)) |
|
|