Coverage for src/bioimageio/core/_model_adapter.py: 88%
123 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 15:59 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 15:59 +0000
1import gc
2import warnings
3from abc import ABC, abstractmethod
4from queue import LifoQueue
5from typing import (
6 Any,
7 Dict,
8 Generic,
9 Iterable,
10 List,
11 Optional,
12 Sequence,
13 Tuple,
14 Union,
15)
17from exceptiongroup import ExceptionGroup
18from loguru import logger
19from numpy.typing import NDArray
20from typing_extensions import TypeVar
22from bioimageio.spec import ValidationSummary
23from bioimageio.spec.model import AnyModelDescr, v0_4
25from ._restore_batch_multi_index import restore_batch_multi_index
26from ._sample_serializer import SampleSerializer, SerializedSampleBlockType
27from .common import PerMember
28from .digest_spec import get_axes_infos, get_member_ids
29from .sample import Sample
30from .tensor import Tensor
33class ModelAdapter(ABC):
34 """
35 Represents model *without* any preprocessing or postprocessing.
37 ```
38 from bioimageio.core import load_description
40 model = load_description(...)
42 # option 1:
43 adapter = create_model_adapter(model)
44 adapter.forward(...)
45 adapter.unload()
47 # option 2:
48 with create_model_adapter(model) as adapter:
49 adapter.forward(...)
50 ```
51 """
53 def __init__(
54 self, model_description: AnyModelDescr, devices: Optional[Sequence[str]]
55 ):
56 super().__init__()
57 self._model_descr = model_description
58 self._input_ids = get_member_ids(model_description.inputs)
59 self._output_ids = get_member_ids(model_description.outputs)
60 self._input_axes = [
61 tuple(a.id for a in get_axes_infos(t)) for t in model_description.inputs
62 ]
63 self._output_axes = [
64 tuple(a.id for a in get_axes_infos(t)) for t in model_description.outputs
65 ]
66 if isinstance(model_description, v0_4.ModelDescr):
67 self._input_is_optional = [False] * len(model_description.inputs)
68 else:
69 self._input_is_optional = [ipt.optional for ipt in model_description.inputs]
71 self._devices = devices
72 self.load()
74 @property
75 def model_descr(self) -> AnyModelDescr:
76 return self._model_descr
78 @abstractmethod
79 def load(self) -> None:
80 self._loaded = True
82 @abstractmethod
83 def forward(
84 self, inputs: PerMember[Optional[Tensor]]
85 ) -> PerMember[Optional[Tensor]]: ...
87 @abstractmethod
88 def unload(self):
89 """Unload model from any devices, freeing their memory.
91 Note:
92 The moder adapter should be considered unusable afterwards.
93 """
94 self._loaded = False
96 def close(self):
97 """Close the model adapter, freeing any resources.
99 Note:
100 The moder adapter should be considered unusable afterwards.
101 """
102 self.unload()
105DeviceType = TypeVar("DeviceType")
106ModelType = TypeVar("ModelType")
109class LocalModelAdapter(ModelAdapter, ABC, Generic[DeviceType, ModelType]):
110 def load(self) -> None:
111 devices = self._devices
112 self._model_queue: LifoQueue[Tuple[DeviceType, ModelType]] = LifoQueue()
113 parsed_devices = self._parse_devices(devices)
114 assert parsed_devices
115 # prioritize devices by order specified by user
116 device_exceptions: Dict[str, Exception] = {}
117 self._initialized_devices: List[str] = []
118 for d in parsed_devices[::-1]:
119 try:
120 model = self._init_model_on_device(d)
121 except Exception as e:
122 device_exceptions[str(d)] = e
123 else:
124 self._model_queue.put((d, model))
125 self._initialized_devices.insert(0, str(d))
127 if self._model_queue.empty():
128 if len(device_exceptions) == 1:
129 raise next(iter(device_exceptions.values()))
130 else:
131 raise ExceptionGroup(
132 "Failed to initialize model on any of the requested devices.",
133 list(device_exceptions.values())[::-1],
134 )
136 if device_exceptions:
137 logger.warning(
138 "Failed to initialize model on some of the requested devices. Successfully initialized on {}, but got the following errors for other devices: {}",
139 self._initialized_devices,
140 device_exceptions,
141 )
143 super().load()
145 @abstractmethod
146 def _parse_devices(self, devices: Optional[Sequence[str]]) -> Sequence[DeviceType]:
147 """Parse devices
149 Note:
150 - May not return an empty sequence.
151 - The order of devices in the returned sequence determines the priority of device usage in the forward pass.
152 First devices has highgest priority, last device has lowest priority.
153 """
155 @abstractmethod
156 def _init_model_on_device(self, device: DeviceType) -> ModelType: ...
158 def forward(
159 self, inputs: PerMember[Optional[Tensor]]
160 ) -> PerMember[Optional[Tensor]]:
161 """
162 Run forward pass of model to get model predictions
164 Note: sample id and stample stat attributes are passed through
166 Args:
167 inputs: input tensors for the model, keyed by member id
168 """
169 if not self._loaded:
170 raise RuntimeError("Model must be `.load()`ed before calling forward()")
172 unexpected = [mid for mid in inputs if mid not in self._input_ids]
173 if unexpected:
174 warnings.warn(f"Got unexpected input tensor IDs: {unexpected}")
176 input_arrays = [
177 (
178 None
179 if (a := inputs.get(in_id)) is None
180 else a.transpose(in_order).to_numpy()
181 )
182 for in_id, in_order in zip(self._input_ids, self._input_axes)
183 ]
185 logger.debug(
186 "NN input shapes: {}",
187 [a.shape if a is not None else None for a in input_arrays],
188 )
189 device, model = self._model_queue.get()
190 try:
191 output_arrays = self._forward_impl(device, model, input_arrays)
192 finally:
193 self._model_queue.put((device, model))
195 logger.debug(
196 "NN output shapes: {}",
197 [a.shape if a is not None else None for a in output_arrays],
198 )
199 if len(output_arrays) > len(self._output_ids):
200 warnings.warn(
201 f"Model produced more outputs ({len(output_arrays)}) than specified in the model description ({len(self._output_ids)}). Extra outputs will be ignored."
202 )
203 output_arrays = output_arrays[: len(self._output_ids)]
205 output_tensors = [
206 None if a is None else Tensor(a, dims=d)
207 for a, d in zip(output_arrays, self._output_axes)
208 ]
209 outputs = {
210 tid: out
211 for tid, out in zip(
212 self._output_ids,
213 output_tensors,
214 )
215 if out is not None
216 }
217 outputs = restore_batch_multi_index(inputs, outputs)
218 return outputs
220 @abstractmethod
221 def _forward_impl(
222 self,
223 device: DeviceType,
224 model: ModelType,
225 input_arrays: Sequence[Optional[NDArray[Any]]],
226 ) -> Union[List[Optional[NDArray[Any]]], Tuple[Optional[NDArray[Any]], ...]]:
227 """framework specific forward implementation"""
229 def unload(self):
230 for _ in range(len(self._initialized_devices)):
231 device, model = self._model_queue.get()
232 try:
233 self._cleanup_pre_model_deletion(device, model)
234 except Exception as e:
235 logger.warning(
236 "Got error during pre-deletion cleanup on device {}: {}", device, e
237 )
238 finally:
239 del model
240 try:
241 self._cleanup_post_model_deletion(device)
242 except Exception as e:
243 logger.warning(
244 "Got error during post-deletion cleanup on device {}: {}", device, e
245 )
247 _ = gc.collect() # deallocate memory
248 super().unload()
250 @abstractmethod
251 def _cleanup_pre_model_deletion(self, device: DeviceType, model: ModelType) -> None:
252 """Clean up before model reference deletion"""
254 @abstractmethod
255 def _cleanup_post_model_deletion(self, device: DeviceType) -> None:
256 """Clean up after model reference deletion"""
259class RemoteModelAdapter(ModelAdapter, ABC, Generic[SerializedSampleBlockType]):
260 """Model adapter to use a remote service for model inference."""
262 def __init__(
263 self,
264 model_description: AnyModelDescr,
265 server: str,
266 sample_serializer: SampleSerializer[SerializedSampleBlockType],
267 ):
268 super().__init__(model_description, devices=None)
269 self._server = server
270 self._serializer = sample_serializer
272 @property
273 def server(self) -> str:
274 return self._server
276 def forward(
277 self, inputs: PerMember[Optional[Tensor]]
278 ) -> PerMember[Optional[Tensor]]:
279 serialized_input = self._serializer.serialize_sample(
280 Sample(
281 members={k: v for k, v in inputs.items() if v is not None},
282 stat={},
283 id=None,
284 )
285 )
286 serialized_output = self._forward_impl(serialized_input)
287 output = self._serializer.deserialize_sample(serialized_output).members
288 output = restore_batch_multi_index(inputs, output)
289 return output
291 @abstractmethod
292 def _forward_impl(
293 self, serialized_input_sample: Iterable[SerializedSampleBlockType]
294 ) -> Iterable[SerializedSampleBlockType]: ...
296 @abstractmethod
297 def test(self) -> Optional[ValidationSummary]:
298 """Run the bioimageio model test."""