catbcs / app.py
jeeveshrg's picture
Harden Space analyze outputs
a39b1c8 verified
Raw
History Blame Contribute Delete
5.99 kB
#!/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)