jeeveshrg commited on
Commit
5d86737
·
verified ·
1 Parent(s): c8557db

Deploy FelineBCS Gradio Space

Browse files
Files changed (4) hide show
  1. README.md +49 -8
  2. app.py +117 -0
  3. felinebcs_predict.py +126 -0
  4. requirements.txt +9 -0
README.md CHANGED
@@ -1,13 +1,54 @@
1
  ---
2
- title: Catbcs
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CatBCS
 
 
 
3
  sdk: gradio
 
 
4
  app_file: app.py
5
+ license: mit
6
  ---
7
 
8
+ # CatBCS / FelineBCS Hugging Face Space
9
+
10
+ This directory contains the minimal files needed to run the FelineBCS Gradio
11
+ inference app as a Hugging Face Space. The public website should call this Space
12
+ API; Vercel should not run the model.
13
+
14
+ ## Create the Space
15
+
16
+ 1. In Hugging Face, create a new Space named `catbcs`.
17
+ 2. Set the Space SDK to **Gradio**.
18
+ 3. Upload or copy these files into the root of the Space repository:
19
+ - `app.py`
20
+ - `felinebcs_predict.py`
21
+ - `requirements.txt`
22
+ - `README.md`
23
+ 4. Let Hugging Face install dependencies and launch `app.py`.
24
+
25
+ ## Model artifacts
26
+
27
+ The trained heads are not committed to the GitHub source repository. Provide the
28
+ model artifacts to the Space separately.
29
+
30
+ Default expected layout:
31
+
32
+ ```text
33
+ models/
34
+ clip_vitb32_regressor.joblib
35
+ clip_vitb32_clf9.joblib
36
+ clip_vitb32_clf4.joblib
37
+ ```
38
+
39
+ If the files live somewhere else in the Space runtime, set:
40
+
41
+ ```text
42
+ FELINEBCS_MODEL_DIR=/path/to/models
43
+ ```
44
+
45
+ Do not hardcode local machine paths. If the artifacts are missing, the Space
46
+ will still start and the app will show a clear "model artifacts unavailable"
47
+ message when a prediction is requested.
48
+
49
+ ## Responsible use
50
+
51
+ CatBCS / FelineBCS is a non-diagnostic research and triage aid. It was trained
52
+ on weak, model-generated labels, not veterinary ground truth. It is not
53
+ veterinary advice, diagnosis, or a substitute for a licensed veterinarian's
54
+ hands-on examination.
app.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """FelineBCS — Gradio inference app.
3
+ Upload a cat (or dog) photo -> Body Condition Score 1-9 + group + uncertainty flag.
4
+
5
+ NON-DIAGNOSTIC. Educational tool built on weak (vision-LLM) labels. Not a substitute
6
+ for veterinary examination.
7
+ """
8
+ import gradio as gr
9
+
10
+ # The model (frozen CLIP backbone + trained heads) is loaded lazily on first
11
+ # prediction so this module can be imported without the model artifacts present
12
+ # (e.g. in CI import smoke tests). It is warmed at launch in __main__ below.
13
+ _MODEL = None
14
+
15
+
16
+ def get_model():
17
+ global _MODEL
18
+ if _MODEL is None:
19
+ from felinebcs_predict import FelineBCS
20
+ _MODEL = FelineBCS()
21
+ return _MODEL
22
+
23
+
24
+ def model_unavailable_message(exc):
25
+ return (
26
+ "## Model artifacts unavailable\n\n"
27
+ f"{exc}\n\n"
28
+ "For deployment, provide the trained head files in `./models/` or set "
29
+ "`FELINEBCS_MODEL_DIR` to the artifact directory. This app is still a "
30
+ "non-diagnostic research tool and is not veterinary advice."
31
+ )
32
+
33
+
34
+ BCS_DESC = {
35
+ 1: "Emaciated — ribs/spine/pelvis visible, no fat, severe waist tuck.",
36
+ 2: "Very thin — bones easily felt, minimal fat.",
37
+ 3: "Thin — ribs easily felt, obvious waist.",
38
+ 4: "Lean — ribs palpable, slight waist, minimal fat pad.",
39
+ 5: "Ideal — well-proportioned, ribs felt with light fat cover, visible waist.",
40
+ 6: "Slightly overweight — ribs felt with difficulty, waist less obvious.",
41
+ 7: "Overweight — ribs hard to feel, rounded abdomen, fat pad present.",
42
+ 8: "Obese — ribs not palpable, no waist, prominent abdominal fat pad.",
43
+ 9: "Severely obese — heavy fat deposits, distended abdomen.",
44
+ }
45
+
46
+ DISCLAIMER = """
47
+ ### ⚠️ Non-diagnostic educational tool
48
+ - **Not veterinary advice.** Body condition scoring by a professional includes *palpation* (feeling ribs/fat), which no photo model can replicate.
49
+ - Labels are **weak** — generated by a vision language model, not clinicians. Expect bias toward "ideal" (BCS 5) and errors on extremes.
50
+ - Trained on a dataset that **mixes cats and dogs** and contains some **contaminated / off-distribution images** (puppies, non-cats, synthetic renders).
51
+ - Best performance on **clear, side-on, full-body** photos of short-haired animals. Fluffy coats, occlusion, odd angles, and close-up faces degrade accuracy.
52
+ - For any health concern, **consult a veterinarian.**
53
+ """
54
+
55
+ def analyze(img):
56
+ if img is None:
57
+ return "Please upload an image.", {}, "", {}
58
+ try:
59
+ r = get_model().predict(img)
60
+ except Exception as exc:
61
+ return model_unavailable_message(exc), {}, "", {"error": str(exc)}
62
+ bcs = r["bcs"]; rd = r["bcs_rounded"]
63
+ # headline
64
+ head = f"## Estimated BCS: **{bcs} / 9** → {r['group']}\n\n*{BCS_DESC[rd]}*"
65
+ # quality / reject warning
66
+ warn = []
67
+ if r["reject"]:
68
+ warn.append(f"🚩 **Low-confidence prediction** (regressor–classifier disagreement "
69
+ f"{r['disagreement']}, above reject threshold). The two model heads disagree "
70
+ f"on this image — treat the score as unreliable. Try a clearer, side-on, "
71
+ f"full-body photo.")
72
+ if r["tta_std"] > 0.6:
73
+ warn.append(f"⚠️ Prediction is unstable across image augmentations "
74
+ f"(TTA std {r['tta_std']}). Interpret with caution.")
75
+ warn_md = "\n\n".join(warn) if warn else "✅ Model heads agree; prediction is within normal confidence."
76
+ # probability distribution over 1-9
77
+ probs = {f"BCS {k}": v for k, v in r["prob9"].items()}
78
+ detail = (f"- **Regressor:** {bcs}\n"
79
+ f"- **Classifier expected value:** {r['classifier_expected']}\n"
80
+ f"- **Group (from BCS):** {r['group']}\n"
81
+ f"- **4-way classifier:** {r['group_clf']} (confidence {r['group_clf_conf']:.0%}, "
82
+ f"{'agrees' if r['group_agree'] else 'disagrees'})\n"
83
+ f"- **Disagreement:** {r['disagreement']} | **TTA std:** {r['tta_std']}")
84
+ api_result = {
85
+ "bcs": r["bcs"],
86
+ "bcs_rounded": r["bcs_rounded"],
87
+ "group": r["group"],
88
+ "group_classifier": r["group_clf"],
89
+ "group_classifier_confidence": r["group_clf_conf"],
90
+ "group_agree": r["group_agree"],
91
+ "classifier_expected": r["classifier_expected"],
92
+ "tta_std": r["tta_std"],
93
+ "disagreement": r["disagreement"],
94
+ "reject": r["reject"],
95
+ "prob9": r["prob9"],
96
+ }
97
+ return head + "\n\n" + warn_md, probs, detail, api_result
98
+
99
+ with gr.Blocks(title="FelineBCS") as demo:
100
+ gr.Markdown("# 🐾 FelineBCS — Cat/Dog Body Condition Score Estimator")
101
+ gr.Markdown("Upload a photo to estimate the 9-point Body Condition Score. "
102
+ "Best with a clear, side-on, full-body shot.")
103
+ with gr.Row():
104
+ with gr.Column():
105
+ inp = gr.Image(type="pil", label="Upload photo")
106
+ btn = gr.Button("Analyze", variant="primary")
107
+ with gr.Column():
108
+ out_head = gr.Markdown()
109
+ out_prob = gr.Label(label="Score distribution (9-way classifier)", num_top_classes=5)
110
+ out_detail = gr.Markdown()
111
+ out_api = gr.JSON(visible=False)
112
+ btn.click(analyze, inputs=inp, outputs=[out_head, out_prob, out_detail, out_api], api_name="analyze")
113
+ inp.change(analyze, inputs=inp, outputs=[out_head, out_prob, out_detail, out_api], api_name=False)
114
+ gr.Markdown(DISCLAIMER)
115
+
116
+ if __name__ == "__main__":
117
+ demo.launch(server_name="0.0.0.0", server_port=7860)
felinebcs_predict.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """FelineBCS inference: frozen CLIP ViT-B/32 backbone + trained heads.
3
+ Predicts Body Condition Score (1-9) with an uncertainty-based reject option.
4
+
5
+ NON-DIAGNOSTIC: weak-label model trained on vision-LLM scores. Not veterinary advice.
6
+ """
7
+ import os
8
+
9
+ import numpy as np
10
+ os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
11
+ import torch, joblib, open_clip
12
+ import torchvision.transforms as T
13
+ from PIL import Image
14
+
15
+ MODEL_DIR_ENV = "FELINEBCS_MODEL_DIR"
16
+ DEFAULT_MODEL_DIR = "models"
17
+ REQUIRED_MODEL_FILES = (
18
+ "clip_vitb32_regressor.joblib",
19
+ "clip_vitb32_clf9.joblib",
20
+ "clip_vitb32_clf4.joblib",
21
+ )
22
+ _MEAN = (0.48145466, 0.4578275, 0.40821073)
23
+ _STD = (0.26862954, 0.26130258, 0.27577711)
24
+ GRP_NAMES = {0: "Underweight (BCS 1-3)", 1: "Ideal (BCS 4-5)",
25
+ 2: "Overweight (BCS 6-7)", 3: "Obese (BCS 8-9)"}
26
+ # Reject threshold on regressor-classifier disagreement (calibrated on test set;
27
+ # ~70% coverage operating point where MAE drops 0.83->0.74).
28
+ DISAGREE_REJECT = 1.5
29
+
30
+
31
+ class MissingModelArtifactsError(RuntimeError):
32
+ """Raised when the trained head artifacts are unavailable."""
33
+
34
+
35
+ def resolve_model_dir(model_dir=None):
36
+ return os.path.expanduser(model_dir or os.environ.get(MODEL_DIR_ENV, DEFAULT_MODEL_DIR))
37
+
38
+
39
+ def validate_model_dir(model_dir):
40
+ missing = [
41
+ os.path.join(model_dir, name)
42
+ for name in REQUIRED_MODEL_FILES
43
+ if not os.path.exists(os.path.join(model_dir, name))
44
+ ]
45
+ if missing:
46
+ missing_list = "\n".join(f"- {path}" for path in missing)
47
+ raise MissingModelArtifactsError(
48
+ "FelineBCS model artifacts were not found.\n"
49
+ f"Set {MODEL_DIR_ENV} to a directory containing the trained heads, "
50
+ f"or place them in ./{DEFAULT_MODEL_DIR}/.\n"
51
+ "Required files:\n"
52
+ f"{missing_list}"
53
+ )
54
+
55
+ def _bcs_to_grp(b):
56
+ b = int(b)
57
+ if b <= 3: return 0
58
+ if b <= 5: return 1
59
+ if b <= 7: return 2
60
+ return 3
61
+
62
+ class FelineBCS:
63
+ def __init__(self, model_dir=None, device=None):
64
+ model_dir = resolve_model_dir(model_dir)
65
+ validate_model_dir(model_dir)
66
+ self.model_dir = model_dir
67
+ if device is None:
68
+ device = os.environ.get("FELINEBCS_DEVICE", "cpu")
69
+ self.device = device
70
+ model, _, self.preprocess = open_clip.create_model_and_transforms(
71
+ "ViT-B-32", pretrained="openai")
72
+ self.visual = model.visual.eval().to(device)
73
+ self.reg = joblib.load(os.path.join(model_dir, "clip_vitb32_regressor.joblib"))
74
+ self.clf9 = joblib.load(os.path.join(model_dir, "clip_vitb32_clf9.joblib"))
75
+ self.clf4 = joblib.load(os.path.join(model_dir, "clip_vitb32_clf4.joblib"))
76
+ self._tta = [self.preprocess,
77
+ self._mk(True, 1.0), self._mk(False, 1.1),
78
+ self._mk(True, 1.1), self._mk(False, 1.2)]
79
+
80
+ @staticmethod
81
+ def _mk(hflip, scale):
82
+ ops = ([T.RandomHorizontalFlip(1.0)] if hflip else []) + \
83
+ [T.Resize(int(224*scale)), T.CenterCrop(224),
84
+ T.ToTensor(), T.Normalize(_MEAN, _STD)]
85
+ return T.Compose(ops)
86
+
87
+ def _feat(self, img, tf):
88
+ with torch.no_grad():
89
+ return self.visual(tf(img).unsqueeze(0).to(self.device)).cpu().numpy()[0]
90
+
91
+ def predict(self, img):
92
+ if isinstance(img, str):
93
+ img = Image.open(img)
94
+ img = img.convert("RGB")
95
+ f = self._feat(img, self.preprocess).reshape(1, -1)
96
+ bcs = float(np.clip(self.reg.predict(f)[0], 1, 9))
97
+ p9 = self.clf9.predict_proba(f)[0]
98
+ ev = float((p9 * self.clf9.classes_).sum())
99
+ p4 = self.clf4.predict_proba(f)[0]
100
+ grp_clf = int(self.clf4.classes_[p4.argmax()])
101
+ # Primary group is derived from the production regressor so the band is
102
+ # always coherent with the reported BCS; the 4-way classifier is a
103
+ # secondary opinion (its agreement is an extra confidence signal).
104
+ grp = _bcs_to_grp(round(bcs))
105
+ # TTA spread for a stability read
106
+ tta = np.array([np.clip(self.reg.predict(self._feat(img, tf).reshape(1, -1))[0], 1, 9)
107
+ for tf in self._tta])
108
+ disagree = abs(bcs - ev)
109
+ return {
110
+ "bcs": round(bcs, 1),
111
+ "bcs_rounded": int(round(bcs)),
112
+ "group": GRP_NAMES[grp],
113
+ "group_clf": GRP_NAMES[grp_clf],
114
+ "group_clf_conf": round(float(p4.max()), 3),
115
+ "group_agree": bool(grp == grp_clf),
116
+ "classifier_expected": round(ev, 1),
117
+ "tta_std": round(float(tta.std()), 2),
118
+ "disagreement": round(disagree, 2),
119
+ "reject": bool(disagree > DISAGREE_REJECT),
120
+ "prob9": {int(c): round(float(p), 3) for c, p in zip(self.clf9.classes_, p9)},
121
+ }
122
+
123
+ if __name__ == "__main__":
124
+ import sys, json
125
+ m = FelineBCS()
126
+ print(json.dumps(m.predict(sys.argv[1]), indent=2))
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ joblib
3
+ numpy
4
+ open-clip-torch
5
+ pandas
6
+ Pillow
7
+ scikit-learn
8
+ torch
9
+ torchvision