File size: 5,993 Bytes
5d86737 fdae8e4 5d86737 a39b1c8 5d86737 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | #!/usr/bin/env python
"""FelineBCS — Gradio inference app.
Upload a cat (or dog) photo -> Body Condition Score 1-9 + group + uncertainty flag.
NON-DIAGNOSTIC. Educational tool built on weak (vision-LLM) labels. Not a substitute
for veterinary examination.
"""
import gradio as gr
try:
from pillow_heif import register_heif_opener
register_heif_opener()
except Exception:
pass
# The model (frozen CLIP backbone + trained heads) is loaded lazily on first
# prediction so this module can be imported without the model artifacts present
# (e.g. in CI import smoke tests). It is warmed at launch in __main__ below.
_MODEL = None
def get_model():
global _MODEL
if _MODEL is None:
from felinebcs_predict import FelineBCS
_MODEL = FelineBCS()
return _MODEL
def model_unavailable_message(exc):
return (
"## Model artifacts unavailable\n\n"
f"{exc}\n\n"
"For deployment, provide the trained head files in `./models/` or set "
"`FELINEBCS_MODEL_DIR` to the artifact directory. This app is still a "
"non-diagnostic research tool and is not veterinary advice."
)
BCS_DESC = {
1: "Emaciated — ribs/spine/pelvis visible, no fat, severe waist tuck.",
2: "Very thin — bones easily felt, minimal fat.",
3: "Thin — ribs easily felt, obvious waist.",
4: "Lean — ribs palpable, slight waist, minimal fat pad.",
5: "Ideal — well-proportioned, ribs felt with light fat cover, visible waist.",
6: "Slightly overweight — ribs felt with difficulty, waist less obvious.",
7: "Overweight — ribs hard to feel, rounded abdomen, fat pad present.",
8: "Obese — ribs not palpable, no waist, prominent abdominal fat pad.",
9: "Severely obese — heavy fat deposits, distended abdomen.",
}
DISCLAIMER = """
### ⚠️ Non-diagnostic educational tool
- **Not veterinary advice.** Body condition scoring by a professional includes *palpation* (feeling ribs/fat), which no photo model can replicate.
- Labels are **weak** — generated by a vision language model, not clinicians. Expect bias toward "ideal" (BCS 5) and errors on extremes.
- Trained on a dataset that **mixes cats and dogs** and contains some **contaminated / off-distribution images** (puppies, non-cats, synthetic renders).
- Best performance on **clear, side-on, full-body** photos of short-haired animals. Fluffy coats, occlusion, odd angles, and close-up faces degrade accuracy.
- For any health concern, **consult a veterinarian.**
"""
def analyze(img):
if img is None:
return "Please upload an image.", {}, "", {}
try:
r = get_model().predict(img)
bcs = float(r["bcs"])
rd = int(r["bcs_rounded"])
group = str(r["group"])
group_clf = str(r["group_clf"])
group_clf_conf = float(r["group_clf_conf"])
group_agree = bool(r["group_agree"])
classifier_expected = float(r["classifier_expected"])
tta_std = float(r["tta_std"])
disagreement = float(r["disagreement"])
reject = bool(r["reject"])
prob9 = {str(k): float(v) for k, v in r["prob9"].items()}
# headline
head = f"## Estimated BCS: **{bcs} / 9** → {group}\n\n*{BCS_DESC[rd]}*"
# quality / reject warning
warn = []
if reject:
warn.append(f"🚩 **Low-confidence prediction** (regressor–classifier disagreement "
f"{disagreement}, above reject threshold). The two model heads disagree "
f"on this image — treat the score as unreliable. Try a clearer, side-on, "
f"full-body photo.")
if tta_std > 0.6:
warn.append(f"⚠️ Prediction is unstable across image augmentations "
f"(TTA std {tta_std}). Interpret with caution.")
warn_md = "\n\n".join(warn) if warn else "✅ Model heads agree; prediction is within normal confidence."
# probability distribution over 1-9
probs = {f"BCS {k}": v for k, v in prob9.items()}
detail = (f"- **Regressor:** {bcs}\n"
f"- **Classifier expected value:** {classifier_expected}\n"
f"- **Group (from BCS):** {group}\n"
f"- **4-way classifier:** {group_clf} (confidence {group_clf_conf:.0%}, "
f"{'agrees' if group_agree else 'disagrees'})\n"
f"- **Disagreement:** {disagreement} | **TTA std:** {tta_std}")
api_result = {
"bcs": bcs,
"bcs_rounded": rd,
"group": group,
"group_classifier": group_clf,
"group_classifier_confidence": group_clf_conf,
"group_agree": group_agree,
"classifier_expected": classifier_expected,
"tta_std": tta_std,
"disagreement": disagreement,
"reject": reject,
"prob9": prob9,
}
except Exception as exc:
return model_unavailable_message(exc), {}, "", {"error": str(exc)}
return head + "\n\n" + warn_md, probs, detail, api_result
with gr.Blocks(title="FelineBCS") as demo:
gr.Markdown("# 🐾 FelineBCS — Cat/Dog Body Condition Score Estimator")
gr.Markdown("Upload a photo to estimate the 9-point Body Condition Score. "
"Best with a clear, side-on, full-body shot.")
with gr.Row():
with gr.Column():
inp = gr.Image(type="pil", label="Upload photo")
btn = gr.Button("Analyze", variant="primary")
with gr.Column():
out_head = gr.Markdown()
out_prob = gr.Label(label="Score distribution (9-way classifier)", num_top_classes=5)
out_detail = gr.Markdown()
out_api = gr.JSON(visible=False)
btn.click(analyze, inputs=inp, outputs=[out_head, out_prob, out_detail, out_api], api_name="analyze")
gr.Markdown(DISCLAIMER)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)
|