Upload README.md with huggingface_hub
Browse files
README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
---
|
| 2 |
license: apache-2.0
|
|
|
|
| 3 |
pipeline_tag: image-classification
|
| 4 |
tags:
|
| 5 |
- medical-imaging
|
|
@@ -116,7 +117,143 @@ whether an image is worth processing at all is satisfied by `family`.
|
|
| 116 |
* Scores are calibrated. Read the measured precision and
|
| 117 |
recall above, not the score, when choosing a policy.
|
| 118 |
|
| 119 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
```python
|
| 122 |
from modalityscan import ModalityClassifier
|
|
|
|
| 1 |
---
|
| 2 |
license: apache-2.0
|
| 3 |
+
library_name: onnx
|
| 4 |
pipeline_tag: image-classification
|
| 5 |
tags:
|
| 6 |
- medical-imaging
|
|
|
|
| 117 |
* Scores are calibrated. Read the measured precision and
|
| 118 |
recall above, not the score, when choosing a policy.
|
| 119 |
|
| 120 |
+
## Quick start (Python + onnxruntime)
|
| 121 |
+
|
| 122 |
+
No clone, no framework - the artifact and its config files are all you need.
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
pip install onnxruntime pillow numpy huggingface_hub
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
```python
|
| 129 |
+
import json, numpy as np, onnxruntime as ort
|
| 130 |
+
from PIL import Image
|
| 131 |
+
from huggingface_hub import hf_hub_download
|
| 132 |
+
|
| 133 |
+
REPO = "vectorsense/modalityscan"
|
| 134 |
+
sess = ort.InferenceSession(hf_hub_download(REPO, "model.int8.onnx"),
|
| 135 |
+
providers=["CPUExecutionProvider"])
|
| 136 |
+
tax = json.load(open(hf_hub_download(REPO, "modality-taxonomy.json"), encoding="utf-8"))
|
| 137 |
+
book = json.load(open(hf_hub_download(REPO, "modality-thresholds.json"), encoding="utf-8"))
|
| 138 |
+
pre = json.load(open(hf_hub_download(REPO, "preprocessor.json"), encoding="utf-8"))
|
| 139 |
+
|
| 140 |
+
MODALITY = tax["levels"]["modality"]["index"]
|
| 141 |
+
FAMILY = tax["levels"]["family"]["index"]
|
| 142 |
+
TIER = {label: tier
|
| 143 |
+
for tier in ("model_trained", "experimental", "not_trained")
|
| 144 |
+
for label in tax["tiers"][tier]["types"]}
|
| 145 |
+
|
| 146 |
+
mean = np.array(pre["image_mean"], np.float32)[:, None, None]
|
| 147 |
+
std = np.array(pre["image_std"], np.float32)[:, None, None]
|
| 148 |
+
|
| 149 |
+
def pixel_values(path, size=224):
|
| 150 |
+
# Letterbox, never centre-crop. The field-of-view outline - ultrasound sector, circular
|
| 151 |
+
# fundus aperture, endoscope vignette - is a top-tier modality cue; cropping discards it.
|
| 152 |
+
image = Image.open(path).convert("RGB")
|
| 153 |
+
scale = size / max(image.size)
|
| 154 |
+
small = image.resize((max(1, round(image.width * scale)),
|
| 155 |
+
max(1, round(image.height * scale))), Image.BILINEAR)
|
| 156 |
+
canvas = Image.new("RGB", (size, size), (0, 0, 0))
|
| 157 |
+
canvas.paste(small, ((size - small.width) // 2, (size - small.height) // 2))
|
| 158 |
+
array = np.asarray(canvas, np.float32) / 255.0
|
| 159 |
+
return ((array.transpose(2, 0, 1) - mean) / std)[None]
|
| 160 |
+
|
| 161 |
+
def softmax(x):
|
| 162 |
+
e = np.exp(x - x.max())
|
| 163 |
+
return e / e.sum()
|
| 164 |
+
|
| 165 |
+
family_logits, modality_logits = sess.run(
|
| 166 |
+
["family_logits", "modality_logits"], {"pixel_values": pixel_values("frame.png")}
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Temperature lives outside the graph so calibration stays configuration, not a re-export.
|
| 170 |
+
T = book["calibration"]["temperature"]
|
| 171 |
+
p_mod = softmax(modality_logits[0] / T["modality"])
|
| 172 |
+
p_fam = softmax(family_logits[0] / T["family"])
|
| 173 |
+
|
| 174 |
+
policy = book["default_policy"]
|
| 175 |
+
floor = book["policies"][policy]["modality"]
|
| 176 |
+
over = book["per_class_overrides"].get(policy, {})
|
| 177 |
+
|
| 178 |
+
# Energy over logits, not softmax: softmax is confident on garbage by construction.
|
| 179 |
+
energy = -float(np.log(np.exp(modality_logits[0] / T["modality"]).sum()))
|
| 180 |
+
if energy > book["ood"]["energy_threshold"][policy]:
|
| 181 |
+
print("out of distribution ->", book["ood"]["fallback_label"])
|
| 182 |
+
else:
|
| 183 |
+
# `not_trained` classes keep their slot because the output width is a published contract,
|
| 184 |
+
# but they are not claims. A raw argmax will eventually return one of them.
|
| 185 |
+
best = next(i for i in np.argsort(-p_mod) if TIER.get(MODALITY[i]) != "not_trained")
|
| 186 |
+
label, score = MODALITY[best], float(p_mod[best])
|
| 187 |
+
if score >= over.get(label, floor):
|
| 188 |
+
print(f"modality: {label} {score:.3f} tier={TIER[label]}")
|
| 189 |
+
else:
|
| 190 |
+
print(f"family: {FAMILY[int(p_fam.argmax())]} {p_fam.max():.3f} (cascade fell back)")
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
Two lines carry most of the correctness. **Letterbox rather than centre-crop** - the field-of-view
|
| 194 |
+
outline is signal, not padding. **Skip `not_trained` classes** - a raw `argmax` will eventually
|
| 195 |
+
hand you a label this model does not stand behind.
|
| 196 |
+
|
| 197 |
+
## Run it as a REST API
|
| 198 |
+
|
| 199 |
+
The companion repo ships a FastAPI service with the cascade, the calibrated thresholds, the
|
| 200 |
+
energy rejection and series aggregation already wired up.
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
pip install fastapi "uvicorn[standard]" onnxruntime pillow numpy
|
| 204 |
+
uvicorn service.app:app --port 5003 # auto-loads model.int8.onnx from ./models
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
curl -s http://127.0.0.1:5003/classify_modality \
|
| 209 |
+
-H "Content-Type: application/json" \
|
| 210 |
+
-d '{"frames":[{"id":"series-1/f000","image_base64":"iVBORw0KGgo..."}],
|
| 211 |
+
"options":{"policy":"balanced"}}'
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
The same call from Python - send a PNG or JPEG, get parsed JSON back:
|
| 215 |
+
|
| 216 |
+
```python
|
| 217 |
+
import base64, requests
|
| 218 |
+
|
| 219 |
+
URL = "http://127.0.0.1:5003/classify_modality"
|
| 220 |
+
|
| 221 |
+
def classify(path, policy="balanced"):
|
| 222 |
+
payload = {
|
| 223 |
+
"frames": [{"id": "series-1/f000",
|
| 224 |
+
"image_base64": base64.b64encode(open(path, "rb").read()).decode()}],
|
| 225 |
+
"options": {"policy": policy, "top_k": 3},
|
| 226 |
+
}
|
| 227 |
+
response = requests.post(URL, json=payload, timeout=30)
|
| 228 |
+
response.raise_for_status() # unknown request fields return 422, never a silent default
|
| 229 |
+
return response.json()
|
| 230 |
+
|
| 231 |
+
frame = classify("frame.png")["results"][0]
|
| 232 |
+
print(frame["modality"]["label"], frame["modality"]["granularity"], frame["modality"]["score"])
|
| 233 |
+
print("out of distribution:", frame["out_of_distribution"])
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
A whole DICOM series in one request, which is the cheapest accuracy in the serving path:
|
| 237 |
+
|
| 238 |
+
```python
|
| 239 |
+
payload = {
|
| 240 |
+
"frames": [{"id": f"series-1/f{i:03d}",
|
| 241 |
+
"image_base64": base64.b64encode(open(p, "rb").read()).decode()}
|
| 242 |
+
for i, p in enumerate(frame_paths)],
|
| 243 |
+
"options": {"policy": "balanced", "aggregate_by": "series"},
|
| 244 |
+
}
|
| 245 |
+
print(requests.post(URL, json=payload, timeout=60).json()["aggregate"])
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
Endpoints: `GET /`, `POST /classify_modality`, `GET /health/ready`, `GET /metadata`.
|
| 249 |
+
`GET /metadata` reports the tier of every class at runtime, so a caller can tell a claimed class
|
| 250 |
+
from an experimental one without reading this card.
|
| 251 |
+
|
| 252 |
+
Prefer the service over the raw-ONNX snippet unless you have a reason not to: it already applies
|
| 253 |
+
the calibrated temperature, the per-class thresholds, the abstention cascade and the rejection
|
| 254 |
+
path. The snippet reimplements those in miniature and will drift from the config.
|
| 255 |
+
|
| 256 |
+
## Usage via the library
|
| 257 |
|
| 258 |
```python
|
| 259 |
from modalityscan import ModalityClassifier
|