bubbaonbubba commited on
Commit
adf9288
·
verified ·
1 Parent(s): d162752

bundle pockethb/inference.py

Browse files
Files changed (1) hide show
  1. pockethb/inference.py +172 -0
pockethb/inference.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference pipeline: load HF bundle, embed photos, optionally personalise.
2
+
3
+ Designed to be the single import-and-use class for chunks 6–8.
4
+
5
+ from pockethb.inference import InferenceSession
6
+ sess = InferenceSession.from_hub() # loads bubbaonbubba/pockethb-base
7
+ raw_hb = sess.predict_aggregate(photo_paths) # global prediction
8
+ sess.calibrate(photo_paths, true_hb_g_per_dL=15.3) # fit affine bias correction
9
+ personal_hb = sess.predict_aggregate(photo_paths) # now personalised
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import pickle
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import torch
19
+ from PIL import Image
20
+
21
+ from .calibration import AffineCalibrator, PersonalHead
22
+ from .embed import _prep_crop, load_backbone
23
+ from .preprocess import shades_of_gray
24
+
25
+
26
+ def _load_image(src) -> np.ndarray:
27
+ """Accept str / Path / PIL.Image / numpy array. Return HxWx3 uint8."""
28
+ if isinstance(src, np.ndarray):
29
+ return src
30
+ if isinstance(src, (str, Path)):
31
+ return np.asarray(Image.open(src).convert("RGB"))
32
+ if isinstance(src, Image.Image):
33
+ return np.asarray(src.convert("RGB"))
34
+ raise TypeError(f"unsupported image source: {type(src)}")
35
+
36
+
37
+ @dataclass
38
+ class InferenceResult:
39
+ raw_per_image: np.ndarray # global prediction per input photo (g/dL)
40
+ raw_aggregate: float # global prediction at session level (mean+std agg)
41
+ personal_per_image: np.ndarray | None = None # post-calibration per photo
42
+ personal_aggregate: float | None = None # post-calibration session level
43
+ method: str = "global" # "global" | "affine" | "mlp"
44
+ n_photos: int = 0
45
+ notes: str = ""
46
+
47
+
48
+ class InferenceSession:
49
+ """Carries the global model bundle + (optional) per-user calibrator."""
50
+
51
+ def __init__(self, bundle: dict, device: str = "cpu"):
52
+ self.bundle = bundle
53
+ self.backbone_name = bundle["backbone_name"]
54
+ self.image_size = int(bundle["image_size"])
55
+ self.sog_p = int(bundle["shades_of_gray_p"])
56
+ self.blender = bundle["blender"]
57
+ self.device = device
58
+ self._backbone = None
59
+ self.calibrator: AffineCalibrator | None = None
60
+ self.personal_head: PersonalHead | None = None
61
+
62
+ @classmethod
63
+ def from_hub(cls, repo_id: str = "bubbaonbubba/pockethb-base", device: str = "cpu") -> "InferenceSession":
64
+ from huggingface_hub import hf_hub_download
65
+
66
+ path = hf_hub_download(repo_id=repo_id, filename="pockethb_base.pkl")
67
+ with open(path, "rb") as f:
68
+ bundle = pickle.load(f)
69
+ return cls(bundle, device=device)
70
+
71
+ @classmethod
72
+ def from_pkl(cls, path: str | Path, device: str = "cpu") -> "InferenceSession":
73
+ with open(path, "rb") as f:
74
+ bundle = pickle.load(f)
75
+ return cls(bundle, device=device)
76
+
77
+ def _get_backbone(self):
78
+ if self._backbone is None:
79
+ self._backbone = load_backbone(self.backbone_name, device=self.device)
80
+ return self._backbone
81
+
82
+ @torch.no_grad()
83
+ def embed_image(self, image) -> np.ndarray:
84
+ """Apply Shades-of-Gray + resize + normalise → frozen backbone → 768-d feature."""
85
+ img = _load_image(image)
86
+ tensor = _prep_crop(img, apply_sog=True).unsqueeze(0).to(self.device)
87
+ feat = self._get_backbone()(tensor).cpu().numpy()[0]
88
+ return feat
89
+
90
+ @torch.no_grad()
91
+ def embed_many(self, images) -> np.ndarray:
92
+ """Embed a list of images. Returns (n, 768) array."""
93
+ feats = np.stack([self.embed_image(img) for img in images], axis=0)
94
+ return feats
95
+
96
+ def _aggregate(self, embs: np.ndarray) -> np.ndarray:
97
+ """Apply the same mean+std per-patient aggregation the global model was trained with."""
98
+ if embs.ndim == 1:
99
+ embs = embs[None, :]
100
+ if embs.shape[0] == 1:
101
+ agg = np.concatenate([embs[0], np.zeros_like(embs[0])])
102
+ else:
103
+ agg = np.concatenate([embs.mean(axis=0), embs.std(axis=0, ddof=0)])
104
+ return agg.astype(np.float32).reshape(1, -1)
105
+
106
+ def predict_per_image(self, images) -> np.ndarray:
107
+ """Per-image global prediction (each photo treated as its own session)."""
108
+ embs = self.embed_many(images)
109
+ preds = []
110
+ for i in range(embs.shape[0]):
111
+ agg = self._aggregate(embs[i : i + 1])
112
+ preds.append(float(self.blender.predict(agg)[0]))
113
+ return np.array(preds, dtype=np.float64)
114
+
115
+ def predict_aggregate(self, images) -> float:
116
+ """One Hb estimate from a session: aggregate all photos via mean+std and predict once."""
117
+ embs = self.embed_many(images)
118
+ agg = self._aggregate(embs)
119
+ raw = float(self.blender.predict(agg)[0])
120
+ if self.calibrator and self.calibrator.fitted:
121
+ return float(self.calibrator.predict(np.array([raw]))[0])
122
+ return raw
123
+
124
+ def calibrate(self, images, true_hb_g_per_dL) -> AffineCalibrator:
125
+ """Fit per-user affine calibration against a known bloodwork reading.
126
+
127
+ true_hb_g_per_dL: scalar (single anchor) or array (multiple paired anchors).
128
+ """
129
+ per = self.predict_per_image(images)
130
+ if np.isscalar(true_hb_g_per_dL):
131
+ targets = np.full(len(per), float(true_hb_g_per_dL))
132
+ else:
133
+ targets = np.asarray(true_hb_g_per_dL, dtype=np.float64).ravel()
134
+ self.calibrator = AffineCalibrator().fit(per, targets)
135
+ return self.calibrator
136
+
137
+ def calibrate_mlp(self, images, true_hb_g_per_dL, **head_kwargs) -> PersonalHead:
138
+ """Fit a per-user MLP head on top of the frozen embeddings."""
139
+ embs = self.embed_many(images)
140
+ if np.isscalar(true_hb_g_per_dL):
141
+ targets = np.full(embs.shape[0], float(true_hb_g_per_dL))
142
+ else:
143
+ targets = np.asarray(true_hb_g_per_dL, dtype=np.float64).ravel()
144
+ self.personal_head = PersonalHead(in_dim=embs.shape[1], **head_kwargs).fit(embs, targets)
145
+ return self.personal_head
146
+
147
+ def run(self, images, true_hb_g_per_dL: float | None = None) -> InferenceResult:
148
+ """Full session-level inference. If true_hb_g_per_dL is given, also fits + applies affine calibration."""
149
+ raw_per = self.predict_per_image(images)
150
+ raw_agg = float(np.mean(raw_per))
151
+
152
+ if true_hb_g_per_dL is not None:
153
+ cal = self.calibrate(images, true_hb_g_per_dL)
154
+ personal_per = cal.predict(raw_per)
155
+ personal_agg = float(np.mean(personal_per))
156
+ return InferenceResult(
157
+ raw_per_image=raw_per,
158
+ raw_aggregate=raw_agg,
159
+ personal_per_image=personal_per,
160
+ personal_aggregate=personal_agg,
161
+ method=f"affine_{cal.mode}",
162
+ n_photos=len(raw_per),
163
+ notes=f"calibrator: a={cal.a:.3f} b={cal.b:+.3f} anchors={cal.n_anchors_used}",
164
+ )
165
+
166
+ return InferenceResult(
167
+ raw_per_image=raw_per,
168
+ raw_aggregate=raw_agg,
169
+ method="global",
170
+ n_photos=len(raw_per),
171
+ notes="no calibration applied",
172
+ )