Deploy FelineBCS Gradio Space
Browse files- README.md +49 -8
- app.py +117 -0
- felinebcs_predict.py +126 -0
- requirements.txt +9 -0
README.md
CHANGED
|
@@ -1,13 +1,54 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 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 |
-
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|