vectorsense commited on
Commit
2a85d5a
·
verified ·
1 Parent(s): 83f50e7

Upload README.md with huggingface_hub

Browse files
Files changed (1) hide show
  1. README.md +138 -1
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
- ## Usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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