Coverage for src/bioimageio/spec/model/v0_5.py: 72%

1710 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 09:17 +0000

1from __future__ import annotations 

2 

3import collections.abc 

4import re 

5import string 

6import warnings 

7from copy import deepcopy 

8from functools import partial 

9from itertools import chain 

10from math import ceil 

11from pathlib import Path, PurePosixPath 

12from tempfile import mkdtemp 

13from textwrap import dedent 

14from typing import ( 

15 TYPE_CHECKING, 

16 Any, 

17 Callable, 

18 ClassVar, 

19 Dict, 

20 Generic, 

21 List, 

22 Literal, 

23 Mapping, 

24 NamedTuple, 

25 Optional, 

26 Sequence, 

27 TypeVar, 

28 Union, 

29 cast, 

30 overload, 

31) 

32 

33import numpy as np 

34from annotated_types import Ge, Gt, Interval, MaxLen, MinLen, Predicate 

35from imageio.v3 import imread, imwrite # pyright: ignore[reportUnknownVariableType] 

36from loguru import logger 

37from numpy.typing import NDArray 

38from pydantic import ( 

39 AfterValidator, 

40 Discriminator, 

41 Field, 

42 RootModel, 

43 SerializationInfo, 

44 SerializerFunctionWrapHandler, 

45 StrictInt, 

46 Tag, 

47 ValidationInfo, 

48 WrapSerializer, 

49 field_validator, 

50 model_serializer, 

51 model_validator, 

52) 

53from pydantic_extra_types.color import Color 

54from typing_extensions import Annotated, Self, TypeAlias, assert_never, get_args 

55 

56from .._internal.common_nodes import ( 

57 InvalidDescr, 

58 KwargsNode, 

59 Node, 

60 NodeWithExplicitlySetFields, 

61) 

62from .._internal.constants import DTYPE_LIMITS 

63from .._internal.field_warning import issue_warning, warn 

64from .._internal.io import BioimageioYamlContent as BioimageioYamlContent 

65from .._internal.io import FileDescr as FileDescr 

66from .._internal.io import ( 

67 FileSource, 

68 WithSuffix, 

69 YamlValue, 

70 extract_file_name, 

71 get_reader, 

72 wo_special_file_name, 

73) 

74from .._internal.io_basics import Sha256 as Sha256 

75from .._internal.io_packaging import ( 

76 FileDescr_package, 

77 package_file_descr_serializer, 

78) 

79from .._internal.io_utils import load_array, open_bioimageio_yaml 

80from .._internal.node_converter import Converter 

81from .._internal.type_guards import is_dict, is_mapping, is_sequence 

82from .._internal.types import ( 

83 FAIR, 

84 AbsoluteTolerance, 

85 LowerCaseIdentifier, 

86 LowerCaseIdentifierAnno, 

87 MismatchedElementsPerMillion, 

88 RelativeTolerance, 

89 validate_identifier, 

90 validate_is_not_keyword, 

91) 

92from .._internal.types import Datetime as Datetime 

93from .._internal.types import Identifier as Identifier 

94from .._internal.types import NotEmpty as NotEmpty 

95from .._internal.types import SiUnit as SiUnit 

96from .._internal.url import HttpUrl as HttpUrl 

97from .._internal.utils import try_all_raise_last 

98from .._internal.validation_context import get_validation_context 

99from .._internal.validator_annotations import RestrictCharacters 

100from .._internal.version_type import Version as Version 

101from .._internal.warning_levels import INFO 

102from ..dataset.v0_2 import DatasetDescr as DatasetDescr02 

103from ..dataset.v0_2 import LinkedDataset as LinkedDataset02 

104from ..dataset.v0_3 import DatasetDescr as DatasetDescr 

105from ..dataset.v0_3 import DatasetId as DatasetId 

106from ..dataset.v0_3 import LinkedDataset as LinkedDataset 

107from ..dataset.v0_3 import Uploader as Uploader 

108from ..generic._v0_3_converter import convert_plain_covers_and_docs_and_icon 

109from ..generic.v0_3 import ( 

110 VALID_COVER_IMAGE_EXTENSIONS as VALID_COVER_IMAGE_EXTENSIONS, 

111) 

112from ..generic.v0_3 import Author as Author 

113from ..generic.v0_3 import BadgeDescr as BadgeDescr 

114from ..generic.v0_3 import CiteEntry as CiteEntry 

115from ..generic.v0_3 import DeprecatedLicenseId as DeprecatedLicenseId 

116from ..generic.v0_3 import Doi as Doi 

117from ..generic.v0_3 import ( 

118 FileDescr_documentation, 

119 GenericModelDescrBase, 

120 LinkedResourceBase, 

121 _author_conv, # pyright: ignore[reportPrivateUsage] 

122 _maintainer_conv, # pyright: ignore[reportPrivateUsage] 

123) 

124from ..generic.v0_3 import LicenseId as LicenseId 

125from ..generic.v0_3 import LinkedResource as LinkedResource 

126from ..generic.v0_3 import Maintainer as Maintainer 

127from ..generic.v0_3 import OrcidId as OrcidId 

128from ..generic.v0_3 import RelativeFilePath as RelativeFilePath 

129from ..generic.v0_3 import ResourceId as ResourceId 

130from .v0_4 import Author as _Author_v0_4 

131from .v0_4 import BinarizeDescr as _BinarizeDescr_v0_4 

132from .v0_4 import CallableFromDepencency as CallableFromDepencency 

133from .v0_4 import CallableFromDepencency as _CallableFromDepencency_v0_4 

134from .v0_4 import CallableFromFile as _CallableFromFile_v0_4 

135from .v0_4 import ClipDescr as _ClipDescr_v0_4 

136from .v0_4 import ImplicitOutputShape as _ImplicitOutputShape_v0_4 

137from .v0_4 import InputTensorDescr as _InputTensorDescr_v0_4 

138from .v0_4 import KnownRunMode as KnownRunMode 

139from .v0_4 import ModelDescr as _ModelDescr04 

140from .v0_4 import ModelDescr as _ModelDescr_v0_4 

141from .v0_4 import OutputTensorDescr as _OutputTensorDescr_v0_4 

142from .v0_4 import ParameterizedInputShape as _ParameterizedInputShape_v0_4 

143from .v0_4 import PostprocessingDescr as _PostprocessingDescr_v0_4 

144from .v0_4 import PreprocessingDescr as _PreprocessingDescr_v0_4 

145from .v0_4 import RunMode as RunMode 

146from .v0_4 import ScaleLinearDescr as _ScaleLinearDescr_v0_4 

147from .v0_4 import ScaleMeanVarianceDescr as _ScaleMeanVarianceDescr_v0_4 

148from .v0_4 import ScaleRangeDescr as _ScaleRangeDescr_v0_4 

149from .v0_4 import SigmoidDescr as _SigmoidDescr_v0_4 

150from .v0_4 import TensorName as _TensorName_v0_4 

151from .v0_4 import ZeroMeanUnitVarianceDescr as _ZeroMeanUnitVarianceDescr_v0_4 

152from .v0_4 import package_weights 

153 

154SpaceUnit = Literal[ 

155 "attometer", 

156 "angstrom", 

157 "centimeter", 

158 "decimeter", 

159 "exameter", 

160 "femtometer", 

161 "foot", 

162 "gigameter", 

163 "hectometer", 

164 "inch", 

165 "kilometer", 

166 "megameter", 

167 "meter", 

168 "micrometer", 

169 "mile", 

170 "millimeter", 

171 "nanometer", 

172 "parsec", 

173 "petameter", 

174 "picometer", 

175 "terameter", 

176 "yard", 

177 "yoctometer", 

178 "yottameter", 

179 "zeptometer", 

180 "zettameter", 

181] 

182"""Space unit compatible to the [OME-Zarr axes specification 0.5](https://ngff.openmicroscopy.org/0.5/#axes-md)""" 

183 

184TimeUnit = Literal[ 

185 "attosecond", 

186 "centisecond", 

187 "day", 

188 "decisecond", 

189 "exasecond", 

190 "femtosecond", 

191 "gigasecond", 

192 "hectosecond", 

193 "hour", 

194 "kilosecond", 

195 "megasecond", 

196 "microsecond", 

197 "millisecond", 

198 "minute", 

199 "nanosecond", 

200 "petasecond", 

201 "picosecond", 

202 "second", 

203 "terasecond", 

204 "yoctosecond", 

205 "yottasecond", 

206 "zeptosecond", 

207 "zettasecond", 

208] 

209"""Time unit compatible to the [OME-Zarr axes specification 0.5](https://ngff.openmicroscopy.org/0.5/#axes-md)""" 

210 

211AxisType = Literal["batch", "channel", "index", "time", "space"] 

212 

213_AXIS_TYPE_MAP: Mapping[str, AxisType] = { 

214 "b": "batch", 

215 "t": "time", 

216 "i": "index", 

217 "c": "channel", 

218 "x": "space", 

219 "y": "space", 

220 "z": "space", 

221} 

222 

223_AXIS_ID_MAP = { 

224 "b": "batch", 

225 "t": "time", 

226 "i": "index", 

227 "c": "channel", 

228 "s": "channel", 

229} 

230 

231WeightsFormat = Literal[ 

232 "keras_hdf5", 

233 "keras_v3", 

234 "onnx", 

235 "pytorch_state_dict", 

236 "tensorflow_js", 

237 "tensorflow_saved_model_bundle", 

238 "torchscript", 

239] 

240 

241 

242class TensorId(LowerCaseIdentifier): 

243 root_model: ClassVar[type[RootModel[Any]]] = RootModel[ 

244 Annotated[LowerCaseIdentifierAnno, MaxLen(32)] 

245 ] 

246 

247 

248def _normalize_axis_id(a: str): 

249 b = str(a).lower() 

250 normalized = _AXIS_ID_MAP.get(b, b) 

251 if a != normalized: 

252 logger.opt(depth=3).warning( 

253 "Normalized axis id from '{}' to '{}'.", a, normalized 

254 ) 

255 return normalized 

256 

257 

258class AxisId(LowerCaseIdentifier): 

259 root_model: ClassVar[type[RootModel[Any]]] = RootModel[ 

260 Annotated[ 

261 NotEmpty[str], 

262 AfterValidator(_normalize_axis_id), 

263 MaxLen(16), 

264 AfterValidator(validate_identifier), 

265 AfterValidator(validate_is_not_keyword), 

266 ] 

267 ] 

268 

269 

270def _is_batch(a: str) -> bool: 

271 return str(a) == "batch" 

272 

273 

274def _is_not_batch(a: str) -> bool: 

275 return not _is_batch(a) 

276 

277 

278NonBatchAxisId = Annotated[AxisId, Predicate(_is_not_batch)] 

279 

280PreprocessingId = Literal[ 

281 "binarize", 

282 "clip", 

283 "ensure_dtype", 

284 "fixed_zero_mean_unit_variance", 

285 "scale_linear", 

286 "scale_range", 

287 "sigmoid", 

288 "softmax", 

289] 

290PostprocessingId = Literal[ 

291 "binarize", 

292 "clip", 

293 "custom", 

294 "ensure_dtype", 

295 "fixed_zero_mean_unit_variance", 

296 "scale_linear", 

297 "scale_mean_variance", 

298 "scale_range", 

299 "sigmoid", 

300 "softmax", 

301 "zero_mean_unit_variance", 

302] 

303 

304 

305SAME_AS_TYPE = "<same as type>" 

306 

307 

308ParameterizedSize_N: TypeAlias = int 

309""" 

310Annotates an integer to calculate a concrete axis size from a `ParameterizedSize`. 

311""" 

312 

313 

314class ParameterizedSize(Node): 

315 """Describes a range of valid tensor axis sizes as `size = min + n*step`. 

316 

317 - **min** and **step** are given by the model description. 

318 - All blocksize paramters n = 0,1,2,... yield a valid `size`. 

319 - A greater blocksize paramter n = 0,1,2,... results in a greater **size**. 

320 This allows to adjust the axis size more generically. 

321 """ 

322 

323 N: ClassVar[type[int]] = ParameterizedSize_N 

324 """Positive integer to parameterize this axis""" 

325 

326 min: Annotated[int, Gt(0)] 

327 step: Annotated[int, Gt(0)] 

328 

329 def validate_size(self, size: int, msg_prefix: str = "") -> int: 

330 if size < self.min: 

331 raise ValueError( 

332 f"{msg_prefix}size {size} < {self.min} (minimum axis size)" 

333 ) 

334 if (size - self.min) % self.step != 0: 

335 raise ValueError( 

336 f"{msg_prefix}size {size} is not parameterized by `min + n*step` =" 

337 + f" `{self.min} + n*{self.step}`" 

338 ) 

339 

340 return size 

341 

342 def get_size(self, n: ParameterizedSize_N) -> int: 

343 return self.min + self.step * n 

344 

345 def get_n(self, s: int) -> ParameterizedSize_N: 

346 """return smallest n parameterizing a size greater or equal than `s`""" 

347 return ceil((s - self.min) / self.step) 

348 

349 

350class DataDependentSize(Node): 

351 min: Annotated[int, Gt(0)] = 1 

352 max: Annotated[int | None, Gt(1)] = None 

353 

354 @model_validator(mode="after") 

355 def _validate_max_gt_min(self): 

356 if self.max is not None and self.min >= self.max: 

357 raise ValueError(f"expected `min` < `max`, but got {self.min}, {self.max}") 

358 

359 return self 

360 

361 def validate_size(self, size: int, msg_prefix: str = "") -> int: 

362 if size < self.min: 

363 raise ValueError(f"{msg_prefix}size {size} < {self.min}") 

364 

365 if self.max is not None and size > self.max: 

366 raise ValueError(f"{msg_prefix}size {size} > {self.max}") 

367 

368 return size 

369 

370 

371class SizeReference(Node): 

372 """A tensor axis size (extent in pixels/frames) defined in relation to a reference axis. 

373 

374 `axis.size = reference.size * reference.scale / axis.scale + offset` 

375 

376 Note: 

377 1. The axis and the referenced axis need to have the same unit (or no unit). 

378 2. Batch axes may not be referenced. 

379 3. Fractions are rounded down. 

380 4. If the reference axis is `concatenable` the referencing axis is assumed to be 

381 `concatenable` as well with the same block order. 

382 

383 Example: 

384 An unisotropic input image of w*h=100*49 pixels depicts a phsical space of 200*196mm². 

385 Let's assume that we want to express the image height h in relation to its width w 

386 instead of only accepting input images of exactly 100*49 pixels 

387 (for example to express a range of valid image shapes by parametrizing w, see `ParameterizedSize`). 

388 

389 >>> w = SpaceInputAxis(id=AxisId("w"), size=100, unit="millimeter", scale=2) 

390 >>> h = SpaceInputAxis( 

391 ... id=AxisId("h"), 

392 ... size=SizeReference(tensor_id=TensorId("input"), axis_id=AxisId("w"), offset=-1), 

393 ... unit="millimeter", 

394 ... scale=4, 

395 ... ) 

396 >>> print(h.size.get_size(h, w)) 

397 49 

398 

399 ⇒ h = w * w.scale / h.scale + offset = 100 * 2mm / 4mm - 1 = 49 

400 """ 

401 

402 tensor_id: TensorId 

403 """tensor id of the reference axis""" 

404 

405 axis_id: AxisId 

406 """axis id of the reference axis""" 

407 

408 offset: StrictInt = 0 

409 

410 def get_size( 

411 self, 

412 axis: ChannelAxis 

413 | IndexInputAxis 

414 | IndexOutputAxis 

415 | TimeInputAxis 

416 | SpaceInputAxis 

417 | TimeOutputAxis 

418 | TimeOutputAxisWithHalo 

419 | SpaceOutputAxis 

420 | SpaceOutputAxisWithHalo, 

421 ref_axis: ChannelAxis 

422 | IndexInputAxis 

423 | IndexOutputAxis 

424 | TimeInputAxis 

425 | SpaceInputAxis 

426 | TimeOutputAxis 

427 | TimeOutputAxisWithHalo 

428 | SpaceOutputAxis 

429 | SpaceOutputAxisWithHalo, 

430 n: ParameterizedSize_N = 0, 

431 ref_size: int | None = None, 

432 ): 

433 """Compute the concrete size for a given axis and its reference axis. 

434 

435 Args: 

436 axis: The axis this [SizeReference][] is the size of. 

437 ref_axis: The reference axis to compute the size from. 

438 n: If the **ref_axis** is parameterized (of type `ParameterizedSize`) 

439 and no fixed **ref_size** is given, 

440 **n** is used to compute the size of the parameterized **ref_axis**. 

441 ref_size: Overwrite the reference size instead of deriving it from 

442 **ref_axis** 

443 (**ref_axis.scale** is still used; any given **n** is ignored). 

444 """ 

445 assert axis.size == self, ( 

446 "Given `axis.size` is not defined by this `SizeReference`" 

447 ) 

448 

449 assert ref_axis.id == self.axis_id, ( 

450 f"Expected `ref_axis.id` to be {self.axis_id}, but got {ref_axis.id}." 

451 ) 

452 

453 assert axis.unit == ref_axis.unit, ( 

454 "`SizeReference` requires `axis` and `ref_axis` to have the same `unit`," 

455 f" but {axis.unit}!={ref_axis.unit}" 

456 ) 

457 if ref_size is None: 

458 if isinstance(ref_axis.size, (int, float)): 

459 ref_size = ref_axis.size 

460 elif isinstance(ref_axis.size, ParameterizedSize): 

461 ref_size = ref_axis.size.get_size(n) 

462 elif isinstance(ref_axis.size, DataDependentSize): 

463 raise ValueError( 

464 "Reference axis referenced in `SizeReference` may not be a `DataDependentSize`." 

465 ) 

466 elif isinstance(ref_axis.size, SizeReference): 

467 raise ValueError( 

468 "Reference axis referenced in `SizeReference` may not be sized by a" 

469 + " `SizeReference` itself." 

470 ) 

471 else: 

472 assert_never(ref_axis.size) 

473 

474 return int(ref_size * ref_axis.scale / axis.scale + self.offset) 

475 

476 @staticmethod 

477 def _get_unit( 

478 axis: ChannelAxis 

479 | IndexInputAxis 

480 | IndexOutputAxis 

481 | TimeInputAxis 

482 | SpaceInputAxis 

483 | TimeOutputAxis 

484 | TimeOutputAxisWithHalo 

485 | SpaceOutputAxis 

486 | SpaceOutputAxisWithHalo, 

487 ): 

488 return axis.unit 

489 

490 

491class AxisBase(NodeWithExplicitlySetFields): 

492 id: AxisId 

493 """An axis id unique across all axes of one tensor.""" 

494 

495 description: Annotated[str, MaxLen(128)] = "" 

496 """A short description of this axis beyond its type and id.""" 

497 

498 

499class WithHalo(Node): 

500 halo: Annotated[int, Ge(1)] 

501 """The halo should be cropped from the output tensor to avoid boundary effects. 

502 It is to be cropped from both sides, i.e. `size_after_crop = size - 2 * halo`. 

503 To document a halo that is already cropped by the model use `size.offset` instead.""" 

504 

505 size: Annotated[ 

506 SizeReference, 

507 Field(examples=[{"tensor_id": "t", "axis_id": "a", "offset": 5}]), 

508 ] 

509 """reference to another axis with an optional offset (see [SizeReference][])""" 

510 

511 

512BATCH_AXIS_ID = AxisId("batch") 

513CHANNEL_AXIS_ID = AxisId("channel") 

514DEFAULT_SPACE_AXIS_ID = AxisId("x") 

515DEFAULT_INDEX_AXIS_ID = AxisId("index") 

516DEFAULT_TIME_AXIS_ID = AxisId("time") 

517 

518 

519class BatchAxis(AxisBase): 

520 implemented_type: ClassVar[Literal["batch"]] = "batch" 

521 if TYPE_CHECKING: 

522 type: Literal["batch"] = "batch" 

523 else: 

524 type: Literal["batch"] 

525 

526 id: Annotated[AxisId, Predicate(_is_batch)] = BATCH_AXIS_ID 

527 size: Literal[1] | None = None 

528 """The batch size may be fixed to 1, 

529 otherwise (the default) it may be chosen arbitrarily depending on available memory""" 

530 

531 @property 

532 def scale(self): 

533 return 1.0 

534 

535 @property 

536 def concatenable(self): 

537 return True 

538 

539 @property 

540 def unit(self): 

541 return None 

542 

543 

544class ChannelAxis(AxisBase): 

545 implemented_type: ClassVar[Literal["channel"]] = "channel" 

546 if TYPE_CHECKING: 

547 type: Literal["channel"] = "channel" 

548 else: 

549 type: Literal["channel"] 

550 

551 id: NonBatchAxisId = CHANNEL_AXIS_ID 

552 

553 channel_names: NotEmpty[list[str]] 

554 """Name/label for each channel. The number of channels is given by `len(channel_names)`.""" 

555 

556 channel_colors: NotEmpty[list[Color]] = Field( 

557 default_factory=cast(Callable[[], list[Color]], list) 

558 ) # real default is set by _set_default_channel_colors() 

559 """Colors for each channel for visualization purposes. 

560 If not given, a default color palette is used: 

561 

562 - For < 8 channels: colorblind-friendly palette from https://www.nature.com/articles/nmeth.1618 (without black) 

563 - For < 21 channels: discrete matplotlib colormap 'tab20b' (redistributed for more even color distribution < 20 channels) 

564 - For >= 21 channels: sample colors from continuous matplotlib colormap 'cividis' 

565 

566 """ 

567 

568 @model_validator(mode="before") 

569 @classmethod 

570 def _set_default_channel_colors(cls, data: Any): 

571 if ( 

572 is_mapping(data) 

573 and not data.get("channel_colors") 

574 and is_sequence(channel_names := data.get("channel_names")) 

575 ): 

576 n_channels = len(channel_names) 

577 data = dict(data) 

578 if n_channels < 8: 

579 # use colorblind-friendly palette from https://www.nature.com/articles/nmeth.1618 

580 # (without black) 

581 data["channel_colors"] = [ 

582 "#E69F00", 

583 "#56B4E9", 

584 "#009E73", 

585 "#F0E442", 

586 "#0072B2", 

587 "#D55E00", 

588 "#CC79A7", 

589 ][:n_channels] 

590 elif n_channels < 21: 

591 # use discrete matplotlib colormap 'tab20b' 

592 # (redistributed for more even color distribution < 20 channels) 

593 data["channel_colors"] = [ 

594 "#393b79", 

595 "#8ca252", 

596 "#e7ba52", 

597 "#e7969c", 

598 "#7b4173", 

599 "#5254a3", 

600 "#b5cf6b", 

601 "#e7cb94", 

602 "#843c39", 

603 "#a55194", 

604 "#6b6ecf", 

605 "#cedb9c", 

606 "#8c6d31", 

607 "#d6616b", 

608 "#ce6dbd", 

609 "#9c9ede", 

610 "#637939", 

611 "#bd9e39", 

612 "#ad494a", 

613 "#de9ed6", 

614 ][:n_channels] 

615 else: 

616 # sample colors from continuous matplotlib colormap 'cividis' 

617 import matplotlib.colors 

618 import matplotlib.pyplot as plt 

619 

620 cmap = plt.colormaps["cividis"].resampled(n_channels) 

621 data["channel_colors"] = [ 

622 matplotlib.colors.to_hex(cmap(i)) for i in range(n_channels) 

623 ] 

624 

625 return data 

626 

627 @property 

628 def size(self) -> int: 

629 return len(self.channel_names) 

630 

631 @property 

632 def concatenable(self): 

633 return False 

634 

635 @property 

636 def scale(self) -> float: 

637 return 1.0 

638 

639 @property 

640 def unit(self): 

641 return None 

642 

643 

644class _WithInputAxisSize(Node): 

645 size: Annotated[ 

646 Annotated[int, Gt(0)] | ParameterizedSize | SizeReference, 

647 Field( 

648 examples=[ 

649 10, 

650 ParameterizedSize(min=32, step=16).model_dump(mode="json"), 

651 {"tensor_id": "t", "axis_id": "a", "offset": 5}, 

652 ] 

653 ), 

654 ] 

655 """The size/length of this axis can be specified as 

656 - fixed integer 

657 - parameterized series of valid sizes ([ParameterizedSize][]) 

658 - reference to another axis with an optional offset ([SizeReference][]) 

659 """ 

660 

661 

662class IndexAxisBase(AxisBase): 

663 implemented_type: ClassVar[Literal["index"]] = "index" 

664 if TYPE_CHECKING: 

665 type: Literal["index"] = "index" 

666 else: 

667 type: Literal["index"] 

668 

669 id: NonBatchAxisId = DEFAULT_INDEX_AXIS_ID 

670 

671 @property 

672 def scale(self) -> float: 

673 return 1.0 

674 

675 @property 

676 def unit(self): 

677 return None 

678 

679 

680class IndexInputAxis(IndexAxisBase, _WithInputAxisSize): 

681 concatenable: bool = False 

682 """If a model has a `concatenable` input axis, it can be processed blockwise, 

683 splitting a longer sample axis into blocks matching its input tensor description. 

684 Output axes are concatenable if they have a [SizeReference][] to a concatenable 

685 input axis. 

686 """ 

687 

688 

689class IndexOutputAxis(IndexAxisBase): 

690 size: Annotated[ 

691 Annotated[int, Gt(0)] | SizeReference | DataDependentSize, 

692 Field(examples=[10, {"tensor_id": "t", "axis_id": "a", "offset": 5}]), 

693 ] 

694 """The size/length of this axis can be specified as 

695 - fixed integer 

696 - reference to another axis with an optional offset ([SizeReference][]) 

697 - data dependent size using [DataDependentSize][] (size is only known after model inference) 

698 """ 

699 

700 

701class TimeAxisBase(AxisBase): 

702 implemented_type: ClassVar[Literal["time"]] = "time" 

703 if TYPE_CHECKING: 

704 type: Literal["time"] = "time" 

705 else: 

706 type: Literal["time"] 

707 

708 id: NonBatchAxisId = DEFAULT_TIME_AXIS_ID 

709 unit: TimeUnit | None = None 

710 scale: Annotated[float, Gt(0)] = 1.0 

711 

712 

713class TimeInputAxis(TimeAxisBase, _WithInputAxisSize): 

714 concatenable: bool = False 

715 """If a model has a `concatenable` input axis, it can be processed blockwise, 

716 splitting a longer sample axis into blocks matching its input tensor description. 

717 Output axes are concatenable if they have a [SizeReference][] to a concatenable 

718 input axis. 

719 """ 

720 

721 

722class SpaceAxisBase(AxisBase): 

723 implemented_type: ClassVar[Literal["space"]] = "space" 

724 if TYPE_CHECKING: 

725 type: Literal["space"] = "space" 

726 else: 

727 type: Literal["space"] 

728 

729 id: Annotated[NonBatchAxisId, Field(examples=["x", "y", "z"])] = ( 

730 DEFAULT_SPACE_AXIS_ID 

731 ) 

732 unit: SpaceUnit | None = None 

733 scale: Annotated[float, Gt(0)] = 1.0 

734 

735 

736class SpaceInputAxis(SpaceAxisBase, _WithInputAxisSize): 

737 concatenable: bool = False 

738 """If a model has a `concatenable` input axis, it can be processed blockwise, 

739 splitting a longer sample axis into blocks matching its input tensor description. 

740 Output axes are concatenable if they have a [SizeReference][] to a concatenable 

741 input axis. 

742 """ 

743 

744 

745INPUT_AXIS_TYPES = ( 

746 BatchAxis, 

747 ChannelAxis, 

748 IndexInputAxis, 

749 TimeInputAxis, 

750 SpaceInputAxis, 

751) 

752"""intended for isinstance comparisons in py<3.10""" 

753 

754_InputAxisUnion = Union[ 

755 BatchAxis, ChannelAxis, IndexInputAxis, TimeInputAxis, SpaceInputAxis 

756] 

757InputAxis = Annotated[_InputAxisUnion, Discriminator("type")] 

758 

759 

760class _WithOutputAxisSize(Node): 

761 size: Annotated[ 

762 Annotated[int, Gt(0)] | SizeReference, 

763 Field(examples=[10, {"tensor_id": "t", "axis_id": "a", "offset": 5}]), 

764 ] 

765 """The size/length of this axis can be specified as 

766 - fixed integer 

767 - reference to another axis with an optional offset (see [SizeReference][]) 

768 """ 

769 

770 

771class TimeOutputAxis(TimeAxisBase, _WithOutputAxisSize): 

772 pass 

773 

774 

775class TimeOutputAxisWithHalo(TimeAxisBase, WithHalo): 

776 pass 

777 

778 

779def _get_halo_axis_discriminator_value(v: Any) -> Literal["with_halo", "wo_halo"]: 

780 if isinstance(v, dict): 

781 return "with_halo" if "halo" in v else "wo_halo" 

782 else: 

783 return "with_halo" if hasattr(v, "halo") else "wo_halo" 

784 

785 

786_TimeOutputAxisUnion = Annotated[ 

787 Union[ 

788 Annotated[TimeOutputAxis, Tag("wo_halo")], 

789 Annotated[TimeOutputAxisWithHalo, Tag("with_halo")], 

790 ], 

791 Discriminator(_get_halo_axis_discriminator_value), 

792] 

793 

794 

795class SpaceOutputAxis(SpaceAxisBase, _WithOutputAxisSize): 

796 pass 

797 

798 

799class SpaceOutputAxisWithHalo(SpaceAxisBase, WithHalo): 

800 pass 

801 

802 

803_SpaceOutputAxisUnion = Annotated[ 

804 Union[ 

805 Annotated[SpaceOutputAxis, Tag("wo_halo")], 

806 Annotated[SpaceOutputAxisWithHalo, Tag("with_halo")], 

807 ], 

808 Discriminator(_get_halo_axis_discriminator_value), 

809] 

810 

811 

812_OutputAxisUnion = Union[ 

813 BatchAxis, ChannelAxis, IndexOutputAxis, _TimeOutputAxisUnion, _SpaceOutputAxisUnion 

814] 

815OutputAxis = Annotated[_OutputAxisUnion, Discriminator("type")] 

816 

817OUTPUT_AXIS_TYPES = ( 

818 BatchAxis, 

819 ChannelAxis, 

820 IndexOutputAxis, 

821 TimeOutputAxis, 

822 TimeOutputAxisWithHalo, 

823 SpaceOutputAxis, 

824 SpaceOutputAxisWithHalo, 

825) 

826"""intended for isinstance comparisons in py<3.10""" 

827 

828 

829AnyAxis = Union[InputAxis, OutputAxis] 

830 

831ANY_AXIS_TYPES = INPUT_AXIS_TYPES + OUTPUT_AXIS_TYPES 

832"""intended for isinstance comparisons in py<3.10""" 

833 

834TVs = Union[ 

835 NotEmpty[List[int]], 

836 NotEmpty[List[float]], 

837 NotEmpty[List[bool]], 

838 NotEmpty[List[str]], 

839] 

840 

841 

842NominalOrOrdinalDType = Literal[ 

843 "float32", 

844 "float64", 

845 "uint8", 

846 "int8", 

847 "uint16", 

848 "int16", 

849 "uint32", 

850 "int32", 

851 "uint64", 

852 "int64", 

853 "bool", 

854] 

855 

856 

857class NominalOrOrdinalDataDescr(Node): 

858 values: TVs 

859 """A fixed set of nominal or an ascending sequence of ordinal values. 

860 In this case `data.type` is required to be an unsigend integer type, e.g. 'uint8'. 

861 String `values` are interpreted as labels for tensor values 0, ..., N. 

862 Note: as YAML 1.2 does not natively support a "set" datatype, 

863 nominal values should be given as a sequence (aka list/array) as well. 

864 """ 

865 

866 type: Annotated[ 

867 NominalOrOrdinalDType, 

868 Field( 

869 examples=[ 

870 "float32", 

871 "uint8", 

872 "uint16", 

873 "int64", 

874 "bool", 

875 ], 

876 ), 

877 ] = "uint8" 

878 

879 @model_validator(mode="after") 

880 def _validate_values_match_type( 

881 self, 

882 ) -> Self: 

883 incompatible: list[Any] = [] 

884 for v in self.values: 

885 if self.type == "bool": 

886 if not isinstance(v, bool): 

887 incompatible.append(v) 

888 elif self.type in DTYPE_LIMITS: 

889 if ( 

890 isinstance(v, (int, float)) 

891 and ( 

892 v < DTYPE_LIMITS[self.type].min 

893 or v > DTYPE_LIMITS[self.type].max 

894 ) 

895 or (isinstance(v, str) and "uint" not in self.type) 

896 or (isinstance(v, float) and "int" in self.type) 

897 ): 

898 incompatible.append(v) 

899 else: 

900 incompatible.append(v) 

901 

902 if len(incompatible) == 5: 

903 incompatible.append("...") 

904 break 

905 

906 if incompatible: 

907 raise ValueError( 

908 f"data type '{self.type}' incompatible with values {incompatible}" 

909 ) 

910 

911 return self 

912 

913 unit: Literal["arbitrary unit"] | SiUnit | None = None 

914 

915 @property 

916 def range(self): 

917 if isinstance(self.values[0], str): 

918 return 0, len(self.values) - 1 

919 else: 

920 return min(self.values), max(self.values) 

921 

922 

923IntervalOrRatioDType = Literal[ 

924 "float32", 

925 "float64", 

926 "uint8", 

927 "int8", 

928 "uint16", 

929 "int16", 

930 "uint32", 

931 "int32", 

932 "uint64", 

933 "int64", 

934] 

935 

936 

937class IntervalOrRatioDataDescr(Node): 

938 type: Annotated[ # TODO: rename to dtype 

939 IntervalOrRatioDType, 

940 Field( 

941 examples=["float32", "float64", "uint8", "uint16"], 

942 ), 

943 ] = "float32" 

944 range: tuple[float | None, float | None] = ( 

945 None, 

946 None, 

947 ) 

948 """Tuple `(minimum, maximum)` specifying the allowed range of the data in this tensor. 

949 `None` corresponds to min/max of what can be expressed by **type**.""" 

950 unit: Literal["arbitrary unit"] | SiUnit = "arbitrary unit" 

951 scale: float = 1.0 

952 """Scale for data on an interval (or ratio) scale.""" 

953 offset: float | None = None 

954 """Offset for data on a ratio scale.""" 

955 

956 @model_validator(mode="before") 

957 def _replace_inf(cls, data: Any): 

958 if is_dict(data) and "range" in data and is_sequence(data["range"]): 

959 forbidden = ( 

960 "inf", 

961 "-inf", 

962 ".inf", 

963 "-.inf", 

964 float("inf"), 

965 float("-inf"), 

966 ) 

967 if any(v in forbidden for v in data["range"]): 

968 issue_warning("replaced 'inf' value", value=data["range"]) 

969 

970 data["range"] = tuple( 

971 (None if v in forbidden else v) for v in data["range"] 

972 ) 

973 

974 return data 

975 

976 

977TensorDataDescr = Union[NominalOrOrdinalDataDescr, IntervalOrRatioDataDescr] 

978 

979 

980class BinarizeKwargs(KwargsNode): 

981 """key word arguments for [BinarizeDescr][]""" 

982 

983 threshold: float 

984 """The fixed threshold""" 

985 

986 

987class BinarizeAlongAxisKwargs(KwargsNode): 

988 """key word arguments for [BinarizeDescr][]""" 

989 

990 threshold: NotEmpty[list[float]] 

991 """The fixed threshold values along `axis`""" 

992 

993 axis: Annotated[NonBatchAxisId, Field(examples=["channel"])] 

994 """The `threshold` axis""" 

995 

996 

997class BinarizeDescr(NodeWithExplicitlySetFields): 

998 """Binarize the tensor with a fixed threshold. 

999 

1000 Values above [BinarizeKwargs.threshold][]/[BinarizeAlongAxisKwargs.threshold][] 

1001 will be set to one, values below the threshold to zero. 

1002 

1003 Examples: 

1004 - in YAML 

1005 ```yaml 

1006 postprocessing: 

1007 - id: binarize 

1008 kwargs: 

1009 axis: 'channel' 

1010 threshold: [0.25, 0.5, 0.75] 

1011 ``` 

1012 - in Python: 

1013 

1014 >>> postprocessing = [BinarizeDescr( 

1015 ... kwargs=BinarizeAlongAxisKwargs( 

1016 ... axis=AxisId('channel'), 

1017 ... threshold=[0.25, 0.5, 0.75], 

1018 ... ) 

1019 ... )] 

1020 """ 

1021 

1022 implemented_id: ClassVar[Literal["binarize"]] = "binarize" 

1023 if TYPE_CHECKING: 

1024 id: Literal["binarize"] = "binarize" 

1025 else: 

1026 id: Literal["binarize"] 

1027 kwargs: BinarizeKwargs | BinarizeAlongAxisKwargs 

1028 

1029 

1030class ClipKwargs(KwargsNode): 

1031 """key word arguments for [ClipDescr][]""" 

1032 

1033 min: float | None = None 

1034 """Minimum value for clipping. 

1035 

1036 Exclusive with [min_percentile][] 

1037 """ 

1038 min_percentile: Annotated[float, Interval(ge=0, lt=100)] | None = None 

1039 """Minimum percentile for clipping. 

1040 

1041 Exclusive with [min][]. 

1042 

1043 In range [0, 100). 

1044 """ 

1045 

1046 max: float | None = None 

1047 """Maximum value for clipping. 

1048 

1049 Exclusive with `max_percentile`. 

1050 """ 

1051 max_percentile: Annotated[float, Interval(gt=1, le=100)] | None = None 

1052 """Maximum percentile for clipping. 

1053 

1054 Exclusive with `max`. 

1055 

1056 In range (1, 100]. 

1057 """ 

1058 

1059 axes: Annotated[Sequence[AxisId] | None, Field(examples=[("batch", "x", "y")])] = ( 

1060 None 

1061 ) 

1062 """The subset of axes to determine percentiles jointly, 

1063 

1064 i.e. axes to reduce to compute min/max from `min_percentile`/`max_percentile`. 

1065 For example to clip 'batch', 'x' and 'y' jointly in a tensor ('batch', 'channel', 'y', 'x') 

1066 resulting in a tensor of equal shape with clipped values per channel, specify `axes=('batch', 'x', 'y')`. 

1067 To clip samples independently, leave out the 'batch' axis. 

1068 

1069 Only valid if `min_percentile` and/or `max_percentile` are set. 

1070 

1071 Default: Compute percentiles over all axes jointly.""" 

1072 

1073 @model_validator(mode="after") 

1074 def _validate(self) -> Self: 

1075 if (self.min is not None) and (self.min_percentile is not None): 

1076 raise ValueError( 

1077 "Only one of `min` and `min_percentile` may be set, not both." 

1078 ) 

1079 if (self.max is not None) and (self.max_percentile is not None): 

1080 raise ValueError( 

1081 "Only one of `max` and `max_percentile` may be set, not both." 

1082 ) 

1083 if ( 

1084 self.min is None 

1085 and self.min_percentile is None 

1086 and self.max is None 

1087 and self.max_percentile is None 

1088 ): 

1089 raise ValueError( 

1090 "At least one of `min`, `min_percentile`, `max`, or `max_percentile` must be set." 

1091 ) 

1092 

1093 if ( 

1094 self.axes is not None 

1095 and self.min_percentile is None 

1096 and self.max_percentile is None 

1097 ): 

1098 raise ValueError( 

1099 "If `axes` is set, at least one of `min_percentile` or `max_percentile` must be set." 

1100 ) 

1101 

1102 return self 

1103 

1104 

1105class ClipDescr(NodeWithExplicitlySetFields): 

1106 """Set tensor values below min to min and above max to max. 

1107 

1108 See `ScaleRangeDescr` for examples. 

1109 """ 

1110 

1111 implemented_id: ClassVar[Literal["clip"]] = "clip" 

1112 if TYPE_CHECKING: 

1113 id: Literal["clip"] = "clip" 

1114 else: 

1115 id: Literal["clip"] 

1116 

1117 kwargs: ClipKwargs 

1118 

1119 

1120class EnsureDtypeKwargs(KwargsNode): 

1121 """key word arguments for [EnsureDtypeDescr][]""" 

1122 

1123 dtype: Literal[ 

1124 "float32", 

1125 "float64", 

1126 "uint8", 

1127 "int8", 

1128 "uint16", 

1129 "int16", 

1130 "uint32", 

1131 "int32", 

1132 "uint64", 

1133 "int64", 

1134 "bool", 

1135 ] 

1136 

1137 

1138class EnsureDtypeDescr(NodeWithExplicitlySetFields): 

1139 """Cast the tensor data type to `EnsureDtypeKwargs.dtype` (if not matching). 

1140 

1141 This can for example be used to ensure the inner neural network model gets a 

1142 different input tensor data type than the fully described bioimage.io model does. 

1143 

1144 Examples: 

1145 The described bioimage.io model (incl. preprocessing) accepts any 

1146 float32-compatible tensor, normalizes it with percentiles and clipping and then 

1147 casts it to uint8, which is what the neural network in this example expects. 

1148 - in YAML 

1149 ```yaml 

1150 inputs: 

1151 - data: 

1152 type: float32 # described bioimage.io model is compatible with any float32 input tensor 

1153 preprocessing: 

1154 - id: scale_range 

1155 kwargs: 

1156 axes: ['y', 'x'] 

1157 max_percentile: 99.8 

1158 min_percentile: 5.0 

1159 - id: clip 

1160 kwargs: 

1161 min: 0.0 

1162 max: 1.0 

1163 - id: ensure_dtype # the neural network of the model requires uint8 

1164 kwargs: 

1165 dtype: uint8 

1166 ``` 

1167 - in Python: 

1168 >>> preprocessing = [ 

1169 ... ScaleRangeDescr( 

1170 ... kwargs=ScaleRangeKwargs( 

1171 ... axes= (AxisId('y'), AxisId('x')), 

1172 ... max_percentile= 99.8, 

1173 ... min_percentile= 5.0, 

1174 ... ) 

1175 ... ), 

1176 ... ClipDescr(kwargs=ClipKwargs(min=0.0, max=1.0)), 

1177 ... EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype="uint8")), 

1178 ... ] 

1179 """ 

1180 

1181 implemented_id: ClassVar[Literal["ensure_dtype"]] = "ensure_dtype" 

1182 if TYPE_CHECKING: 

1183 id: Literal["ensure_dtype"] = "ensure_dtype" 

1184 else: 

1185 id: Literal["ensure_dtype"] 

1186 

1187 kwargs: EnsureDtypeKwargs 

1188 

1189 

1190class ScaleLinearKwargs(KwargsNode): 

1191 """Key word arguments for [ScaleLinearDescr][]""" 

1192 

1193 gain: float = 1.0 

1194 """multiplicative factor""" 

1195 

1196 offset: float = 0.0 

1197 """additive term""" 

1198 

1199 @model_validator(mode="after") 

1200 def _validate(self) -> Self: 

1201 if self.gain == 1.0 and self.offset == 0.0: 

1202 raise ValueError( 

1203 "Redundant linear scaling not allowd. Set `gain` != 1.0 and/or `offset`" 

1204 + " != 0.0." 

1205 ) 

1206 

1207 return self 

1208 

1209 

1210class ScaleLinearAlongAxisKwargs(KwargsNode): 

1211 """Key word arguments for [ScaleLinearDescr][]""" 

1212 

1213 axis: Annotated[NonBatchAxisId, Field(examples=["channel"])] 

1214 """The axis of gain and offset values.""" 

1215 

1216 gain: float | NotEmpty[list[float]] = 1.0 

1217 """multiplicative factor""" 

1218 

1219 offset: float | NotEmpty[list[float]] = 0.0 

1220 """additive term""" 

1221 

1222 @model_validator(mode="after") 

1223 def _validate(self) -> Self: 

1224 if isinstance(self.gain, list): 

1225 if isinstance(self.offset, list): 

1226 if len(self.gain) != len(self.offset): 

1227 raise ValueError( 

1228 f"Size of `gain` ({len(self.gain)}) and `offset` ({len(self.offset)}) must match." 

1229 ) 

1230 else: 

1231 self.offset = [float(self.offset)] * len(self.gain) 

1232 elif isinstance(self.offset, list): 

1233 self.gain = [float(self.gain)] * len(self.offset) 

1234 else: 

1235 raise ValueError( 

1236 "Do not specify an `axis` for scalar gain and offset values." 

1237 ) 

1238 

1239 if all(g == 1.0 for g in self.gain) and all(off == 0.0 for off in self.offset): 

1240 raise ValueError( 

1241 "Redundant linear scaling not allowd. Set `gain` != 1.0 and/or `offset`" 

1242 + " != 0.0." 

1243 ) 

1244 

1245 return self 

1246 

1247 

1248class ScaleLinearDescr(NodeWithExplicitlySetFields): 

1249 """Fixed linear scaling. 

1250 

1251 Examples: 

1252 1. Scale with scalar gain and offset 

1253 - in YAML 

1254 ```yaml 

1255 preprocessing: 

1256 - id: scale_linear 

1257 kwargs: 

1258 gain: 2.0 

1259 offset: 3.0 

1260 ``` 

1261 - in Python: 

1262 

1263 >>> preprocessing = [ 

1264 ... ScaleLinearDescr(kwargs=ScaleLinearKwargs(gain= 2.0, offset=3.0)) 

1265 ... ] 

1266 

1267 2. Independent scaling along an axis 

1268 - in YAML 

1269 ```yaml 

1270 preprocessing: 

1271 - id: scale_linear 

1272 kwargs: 

1273 axis: 'channel' 

1274 gain: [1.0, 2.0, 3.0] 

1275 ``` 

1276 - in Python: 

1277 

1278 >>> preprocessing = [ 

1279 ... ScaleLinearDescr( 

1280 ... kwargs=ScaleLinearAlongAxisKwargs( 

1281 ... axis=AxisId("channel"), 

1282 ... gain=[1.0, 2.0, 3.0], 

1283 ... ) 

1284 ... ) 

1285 ... ] 

1286 

1287 """ 

1288 

1289 implemented_id: ClassVar[Literal["scale_linear"]] = "scale_linear" 

1290 if TYPE_CHECKING: 

1291 id: Literal["scale_linear"] = "scale_linear" 

1292 else: 

1293 id: Literal["scale_linear"] 

1294 kwargs: ScaleLinearKwargs | ScaleLinearAlongAxisKwargs 

1295 

1296 

1297class SigmoidDescr(NodeWithExplicitlySetFields): 

1298 """The logistic sigmoid function, a.k.a. expit function. 

1299 

1300 Examples: 

1301 - in YAML 

1302 ```yaml 

1303 postprocessing: 

1304 - id: sigmoid 

1305 ``` 

1306 - in Python: 

1307 

1308 >>> postprocessing = [SigmoidDescr()] 

1309 """ 

1310 

1311 implemented_id: ClassVar[Literal["sigmoid"]] = "sigmoid" 

1312 if TYPE_CHECKING: 

1313 id: Literal["sigmoid"] = "sigmoid" 

1314 else: 

1315 id: Literal["sigmoid"] 

1316 

1317 @property 

1318 def kwargs(self) -> KwargsNode: 

1319 """empty kwargs""" 

1320 return KwargsNode() 

1321 

1322 

1323class SoftmaxKwargs(KwargsNode): 

1324 """key word arguments for [SoftmaxDescr][]""" 

1325 

1326 axis: Annotated[NonBatchAxisId, Field(examples=["channel"])] = CHANNEL_AXIS_ID 

1327 """The axis to apply the softmax function along. 

1328 Note: 

1329 Defaults to 'channel' axis 

1330 (which may not exist, in which case 

1331 a different axis id has to be specified). 

1332 """ 

1333 

1334 

1335class SoftmaxDescr(NodeWithExplicitlySetFields): 

1336 """The softmax function. 

1337 

1338 Examples: 

1339 - in YAML 

1340 ```yaml 

1341 postprocessing: 

1342 - id: softmax 

1343 kwargs: 

1344 axis: channel 

1345 ``` 

1346 - in Python: 

1347 

1348 >>> postprocessing = [SoftmaxDescr(kwargs=SoftmaxKwargs(axis=AxisId("channel")))] 

1349 """ 

1350 

1351 implemented_id: ClassVar[Literal["softmax"]] = "softmax" 

1352 if TYPE_CHECKING: 

1353 id: Literal["softmax"] = "softmax" 

1354 else: 

1355 id: Literal["softmax"] 

1356 

1357 kwargs: SoftmaxKwargs = Field(default_factory=SoftmaxKwargs.model_construct) 

1358 

1359 

1360class _StardistPostprocessingKwargsBase(KwargsNode): 

1361 """key word arguments for [StardistPostprocessingDescr][]""" 

1362 

1363 prob_threshold: float 

1364 """The probability threshold for object candidate selection.""" 

1365 

1366 nms_threshold: float 

1367 """The IoU threshold for non-maximum suppression.""" 

1368 

1369 n_rays: int 

1370 """Number of radial lines (rays) cast from the center of an object to its boundary.""" 

1371 

1372 

1373class StardistPostprocessingKwargs2D(_StardistPostprocessingKwargsBase): 

1374 grid: tuple[int, int] 

1375 """Grid size of network predictions.""" 

1376 

1377 b: int | tuple[tuple[int, int], tuple[int, int]] 

1378 """Border region in which object probability is set to zero.""" 

1379 

1380 

1381class StardistPostprocessingKwargs3D(_StardistPostprocessingKwargsBase): 

1382 grid: tuple[int, int, int] 

1383 """Grid size of network predictions.""" 

1384 

1385 b: int | tuple[tuple[int, int], tuple[int, int], tuple[int, int]] 

1386 """Border region in which object probability is set to zero.""" 

1387 

1388 anisotropy: tuple[float, float, float] 

1389 """Anisotropy factors for 3D star-convex polyhedra, i.e. the physical pixel size along each spatial axis.""" 

1390 

1391 overlap_label: int | None = None 

1392 """Optional label to apply to any area of overlapping predicted objects.""" 

1393 

1394 

1395class StardistPostprocessingDescr(NodeWithExplicitlySetFields): 

1396 """Stardist postprocessing including non-maximum suppression and converting polygon representations to instance labels 

1397 

1398 as described in: 

1399 - Uwe Schmidt, Martin Weigert, Coleman Broaddus, and Gene Myers. 

1400 [*Cell Detection with Star-convex Polygons*](https://arxiv.org/abs/1806.03535). 

1401 International Conference on Medical Image Computing and Computer-Assisted Intervention (MICCAI), Granada, Spain, September 2018. 

1402 - Martin Weigert, Uwe Schmidt, Robert Haase, Ko Sugawara, and Gene Myers. 

1403 [*Star-convex Polyhedra for 3D Object Detection and Segmentation in Microscopy*](http://openaccess.thecvf.com/content_WACV_2020/papers/Weigert_Star-convex_Polyhedra_for_3D_Object_Detection_and_Segmentation_in_Microscopy_WACV_2020_paper.pdf). 

1404 The IEEE Winter Conference on Applications of Computer Vision (WACV), Snowmass Village, Colorado, March 2020. 

1405 

1406 Note: Only available if the `stardist` package is installed. 

1407 """ 

1408 

1409 implemented_id: ClassVar[Literal["stardist_postprocessing"]] = ( 

1410 "stardist_postprocessing" 

1411 ) 

1412 if TYPE_CHECKING: 

1413 id: Literal["stardist_postprocessing"] = "stardist_postprocessing" 

1414 else: 

1415 id: Literal["stardist_postprocessing"] 

1416 

1417 kwargs: StardistPostprocessingKwargs2D | StardistPostprocessingKwargs3D 

1418 

1419 

1420class CellposeFlowDynamicsKwargs(KwargsNode): 

1421 """key word arguments for [CellposeFlowDynamicsDescr][]""" 

1422 

1423 cellprob_threshold: float 

1424 flow_threshold: float 

1425 do_3D: bool 

1426 min_size: int = 15 

1427 """Minimum size of objects to keep, in pixels. Default is 15, which is the default in Cellpose. Set to 0 to disable filtering by size.""" 

1428 output_dtype: Literal["uint16", "uint32"] = "uint16" 

1429 

1430 

1431class CellposeFlowDynamicsDescr(NodeWithExplicitlySetFields): 

1432 """Cellpose flow dynamics postprocessing as described in: 

1433 - Carsen Stringer and Marius Pachitariu. [*Cellpose: a generalist algorithm for cellular segmentation*](https://www.nature.com/articles/s41592-020-01018-x). Nature Methods, 2021. 

1434 

1435 Note: Only available if the `cellpose` package is installed. 

1436 """ 

1437 

1438 implemented_id: ClassVar[Literal["cellpose_flow_dynamics"]] = ( 

1439 "cellpose_flow_dynamics" 

1440 ) 

1441 if TYPE_CHECKING: 

1442 id: Literal["cellpose_flow_dynamics"] = "cellpose_flow_dynamics" 

1443 else: 

1444 id: Literal["cellpose_flow_dynamics"] 

1445 

1446 kwargs: CellposeFlowDynamicsKwargs 

1447 

1448 

1449class CustomProcessingDescr(NodeWithExplicitlySetFields, FileDescr): 

1450 """Custom (post)processing op — source file shipped inline with the model. 

1451 

1452 Supports (post)processing that cannot be expressed by the built-in named 

1453 operations (watershed, connected components, etc.) 

1454 using a simple Python callable interface. 

1455 

1456 The op is implemented in a ``.py`` file packaged alongside the model weights. 

1457 Two styles are supported: 

1458 

1459 *Callable class* — kwargs go to ``__init__``, tensors arrive in ``__call__``: 

1460 

1461 .. code-block:: python 

1462 

1463 # my_postprocess.py 

1464 import numpy as np 

1465 

1466 class my_postprocess: 

1467 def __init__(self, threshold: float = 0.5) -> None: 

1468 self.threshold = threshold 

1469 def __call__(self, *arrays: np.ndarray) -> np.ndarray: 

1470 # arrays = model output tensors in rdf.yaml declaration order 

1471 return (arrays[0] > self.threshold).astype(np.uint8) 

1472 

1473 *Factory function* — alternative closure style, identical runtime behaviour: 

1474 

1475 .. code-block:: python 

1476 

1477 # my_postprocess.py 

1478 import numpy as np 

1479 

1480 def my_postprocess(threshold: float = 0.5): 

1481 def run(*arrays: np.ndarray) -> np.ndarray: 

1482 return (arrays[0] > threshold).astype(np.uint8) 

1483 return run 

1484 

1485 Reference it in ``rdf.yaml`` with the source file included in the package: 

1486 

1487 .. code-block:: yaml 

1488 

1489 postprocessing: 

1490 - id: custom 

1491 callable: my_postprocess # class or function name in source 

1492 source: my_postprocess.py # packaged alongside weights 

1493 sha256: <hash> # sha256 of the source file 

1494 kwargs: # forwarded to __init__ / factory 

1495 threshold: 0.5 

1496 

1497 **Security:** source files are SHA-256 verified before execution. 

1498 Execution requires explicit opt-in in bioimageio.core and curator 

1499 review before Zoo publication. 

1500 """ 

1501 

1502 implemented_id: ClassVar[Literal["custom"]] = "custom" 

1503 if TYPE_CHECKING: 

1504 id: Literal["custom"] = "custom" 

1505 else: 

1506 id: Literal["custom"] 

1507 

1508 callable: Annotated[ 

1509 str, 

1510 Field(examples=["my_postprocess_factory", "MyPostprocessClass"]), 

1511 ] 

1512 """Name of the callable class or factory function defined in ``source``. 

1513 

1514 At runtime: ``op = callable(**kwargs)``, then ``result = op(*output_tensors)`` 

1515 per image. Both a class with ``__call__`` and a factory function returning 

1516 a callable satisfy this protocol.""" 

1517 

1518 source: Annotated[FileSource, AfterValidator(wo_special_file_name)] 

1519 """Python source file (included when packaging the model).""" 

1520 

1521 kwargs: dict[str, YamlValue] = Field( 

1522 default_factory=cast(Callable[[], Dict[str, YamlValue]], dict) 

1523 ) 

1524 """Keyword arguments forwarded to the callable (``__init__`` or factory).""" 

1525 

1526 @model_serializer(mode="wrap", when_used="unless-none") 

1527 def _serialize( 

1528 self, nxt: SerializerFunctionWrapHandler, info: SerializationInfo 

1529 ) -> dict[str, YamlValue]: 

1530 return package_file_descr_serializer(self, nxt, info) 

1531 

1532 

1533class FixedZeroMeanUnitVarianceKwargs(KwargsNode): 

1534 """key word arguments for [FixedZeroMeanUnitVarianceDescr][]""" 

1535 

1536 mean: float 

1537 """The mean value to normalize with.""" 

1538 

1539 std: Annotated[float, Ge(1e-6)] 

1540 """The standard deviation value to normalize with.""" 

1541 

1542 

1543class FixedZeroMeanUnitVarianceAlongAxisKwargs(KwargsNode): 

1544 """key word arguments for [FixedZeroMeanUnitVarianceDescr][]""" 

1545 

1546 mean: NotEmpty[list[float]] 

1547 """The mean value(s) to normalize with.""" 

1548 

1549 std: NotEmpty[list[Annotated[float, Ge(1e-6)]]] 

1550 """The standard deviation value(s) to normalize with. 

1551 Size must match `mean` values.""" 

1552 

1553 axis: Annotated[NonBatchAxisId, Field(examples=["channel", "index"])] 

1554 """The axis of the mean/std values to normalize each entry along that dimension 

1555 separately.""" 

1556 

1557 @model_validator(mode="after") 

1558 def _mean_and_std_match(self) -> Self: 

1559 if len(self.mean) != len(self.std): 

1560 raise ValueError( 

1561 f"Size of `mean` ({len(self.mean)}) and `std` ({len(self.std)})" 

1562 + " must match." 

1563 ) 

1564 

1565 return self 

1566 

1567 

1568class FixedZeroMeanUnitVarianceDescr(NodeWithExplicitlySetFields): 

1569 """Subtract a given mean and divide by the standard deviation. 

1570 

1571 Normalize with fixed, precomputed values for 

1572 `FixedZeroMeanUnitVarianceKwargs.mean` and `FixedZeroMeanUnitVarianceKwargs.std` 

1573 Use `FixedZeroMeanUnitVarianceAlongAxisKwargs` for independent scaling along given 

1574 axes. 

1575 

1576 Examples: 

1577 1. scalar value for whole tensor 

1578 - in YAML 

1579 ```yaml 

1580 preprocessing: 

1581 - id: fixed_zero_mean_unit_variance 

1582 kwargs: 

1583 mean: 103.5 

1584 std: 13.7 

1585 ``` 

1586 - in Python 

1587 >>> preprocessing = [FixedZeroMeanUnitVarianceDescr( 

1588 ... kwargs=FixedZeroMeanUnitVarianceKwargs(mean=103.5, std=13.7) 

1589 ... )] 

1590 

1591 2. independently along an axis 

1592 - in YAML 

1593 ```yaml 

1594 preprocessing: 

1595 - id: fixed_zero_mean_unit_variance 

1596 kwargs: 

1597 axis: channel 

1598 mean: [101.5, 102.5, 103.5] 

1599 std: [11.7, 12.7, 13.7] 

1600 ``` 

1601 - in Python 

1602 >>> preprocessing = [FixedZeroMeanUnitVarianceDescr( 

1603 ... kwargs=FixedZeroMeanUnitVarianceAlongAxisKwargs( 

1604 ... axis=AxisId("channel"), 

1605 ... mean=[101.5, 102.5, 103.5], 

1606 ... std=[11.7, 12.7, 13.7], 

1607 ... ) 

1608 ... )] 

1609 """ 

1610 

1611 implemented_id: ClassVar[Literal["fixed_zero_mean_unit_variance"]] = ( 

1612 "fixed_zero_mean_unit_variance" 

1613 ) 

1614 if TYPE_CHECKING: 

1615 id: Literal["fixed_zero_mean_unit_variance"] = "fixed_zero_mean_unit_variance" 

1616 else: 

1617 id: Literal["fixed_zero_mean_unit_variance"] 

1618 

1619 kwargs: FixedZeroMeanUnitVarianceKwargs | FixedZeroMeanUnitVarianceAlongAxisKwargs 

1620 

1621 

1622class ZeroMeanUnitVarianceKwargs(KwargsNode): 

1623 """key word arguments for [ZeroMeanUnitVarianceDescr][]""" 

1624 

1625 axes: Annotated[Sequence[AxisId] | None, Field(examples=[("batch", "x", "y")])] = ( 

1626 None 

1627 ) 

1628 """The subset of axes to normalize jointly, i.e. axes to reduce to compute mean/std. 

1629 For example to normalize 'batch', 'x' and 'y' jointly in a tensor ('batch', 'channel', 'y', 'x') 

1630 resulting in a tensor of equal shape normalized per channel, specify `axes=('batch', 'x', 'y')`. 

1631 To normalize each sample independently leave out the 'batch' axis. 

1632 Default: Scale all axes jointly.""" 

1633 

1634 eps: Annotated[float, Interval(gt=0, le=0.1)] = 1e-6 

1635 """epsilon for numeric stability: `out = (tensor - mean) / (std + eps)`.""" 

1636 

1637 

1638class ZeroMeanUnitVarianceDescr(NodeWithExplicitlySetFields): 

1639 """Subtract mean and divide by variance. 

1640 

1641 Examples: 

1642 Subtract tensor mean and variance 

1643 - in YAML 

1644 ```yaml 

1645 preprocessing: 

1646 - id: zero_mean_unit_variance 

1647 ``` 

1648 - in Python 

1649 >>> preprocessing = [ZeroMeanUnitVarianceDescr()] 

1650 """ 

1651 

1652 implemented_id: ClassVar[Literal["zero_mean_unit_variance"]] = ( 

1653 "zero_mean_unit_variance" 

1654 ) 

1655 if TYPE_CHECKING: 

1656 id: Literal["zero_mean_unit_variance"] = "zero_mean_unit_variance" 

1657 else: 

1658 id: Literal["zero_mean_unit_variance"] 

1659 

1660 kwargs: ZeroMeanUnitVarianceKwargs = Field( 

1661 default_factory=ZeroMeanUnitVarianceKwargs.model_construct 

1662 ) 

1663 

1664 

1665class ScaleRangeKwargs(KwargsNode): 

1666 """key word arguments for [ScaleRangeDescr][] 

1667 

1668 For `min_percentile`=0.0 (the default) and `max_percentile`=100 (the default) 

1669 this processing step normalizes data to the [0, 1] intervall. 

1670 For other percentiles the normalized values will partially be outside the [0, 1] 

1671 intervall. Use `ScaleRange` followed by `ClipDescr` if you want to limit the 

1672 normalized values to a range. 

1673 """ 

1674 

1675 axes: Annotated[Sequence[AxisId] | None, Field(examples=[("batch", "x", "y")])] = ( 

1676 None 

1677 ) 

1678 """The subset of axes to normalize jointly, i.e. axes to reduce to compute the min/max percentile value. 

1679 For example to normalize 'batch', 'x' and 'y' jointly in a tensor ('batch', 'channel', 'y', 'x') 

1680 resulting in a tensor of equal shape normalized per channel, specify `axes=('batch', 'x', 'y')`. 

1681 To normalize samples independently, leave out the "batch" axis. 

1682 Default: Scale all axes jointly.""" 

1683 

1684 min_percentile: Annotated[float, Interval(ge=0, lt=100)] = 0.0 

1685 """The lower percentile used to determine the value to align with zero.""" 

1686 

1687 max_percentile: Annotated[float, Interval(gt=1, le=100)] = 100.0 

1688 """The upper percentile used to determine the value to align with one. 

1689 Has to be bigger than `min_percentile`. 

1690 The range is 1 to 100 instead of 0 to 100 to avoid mistakenly 

1691 accepting percentiles specified in the range 0.0 to 1.0.""" 

1692 

1693 eps: Annotated[float, Interval(gt=0, le=0.1)] = 1e-6 

1694 """Epsilon for numeric stability. 

1695 `out = (tensor - v_lower) / (v_upper - v_lower + eps)`; 

1696 with `v_lower,v_upper` values at the respective percentiles.""" 

1697 

1698 reference_tensor: TensorId | None = None 

1699 """ID of the unprocessed input tensor to compute the percentiles from. 

1700 Default: The tensor itself. 

1701 """ 

1702 

1703 @field_validator("max_percentile", mode="after") 

1704 @classmethod 

1705 def min_smaller_max(cls, value: float, info: ValidationInfo) -> float: 

1706 if (min_p := info.data["min_percentile"]) >= value: 

1707 raise ValueError(f"min_percentile {min_p} >= max_percentile {value}") 

1708 

1709 return value 

1710 

1711 

1712class ScaleRangeDescr(NodeWithExplicitlySetFields): 

1713 """Scale with percentiles. 

1714 

1715 Examples: 

1716 1. Scale linearly to map 5th percentile to 0 and 99.8th percentile to 1.0 

1717 - in YAML 

1718 ```yaml 

1719 preprocessing: 

1720 - id: scale_range 

1721 kwargs: 

1722 axes: ['y', 'x'] 

1723 max_percentile: 99.8 

1724 min_percentile: 5.0 

1725 ``` 

1726 - in Python 

1727 

1728 >>> preprocessing = [ 

1729 ... ScaleRangeDescr( 

1730 ... kwargs=ScaleRangeKwargs( 

1731 ... axes= (AxisId('y'), AxisId('x')), 

1732 ... max_percentile= 99.8, 

1733 ... min_percentile= 5.0, 

1734 ... ) 

1735 ... ) 

1736 ... ] 

1737 

1738 2. Combine the above scaling with additional clipping to clip values outside the range given by the percentiles. 

1739 - in YAML 

1740 ```yaml 

1741 preprocessing: 

1742 - id: scale_range 

1743 kwargs: 

1744 axes: ['y', 'x'] 

1745 max_percentile: 99.8 

1746 min_percentile: 5.0 

1747 - id: clip 

1748 kwargs: 

1749 min: 0.0 

1750 max: 1.0 

1751 ``` 

1752 - in Python 

1753 

1754 >>> preprocessing = [ 

1755 ... ScaleRangeDescr( 

1756 ... kwargs=ScaleRangeKwargs( 

1757 ... axes= (AxisId('y'), AxisId('x')), 

1758 ... max_percentile= 99.8, 

1759 ... min_percentile= 5.0, 

1760 ... ) 

1761 ... ), 

1762 ... ClipDescr( 

1763 ... kwargs=ClipKwargs( 

1764 ... min=0.0, 

1765 ... max=1.0, 

1766 ... ) 

1767 ... ), 

1768 ... ] 

1769 

1770 """ 

1771 

1772 implemented_id: ClassVar[Literal["scale_range"]] = "scale_range" 

1773 if TYPE_CHECKING: 

1774 id: Literal["scale_range"] = "scale_range" 

1775 else: 

1776 id: Literal["scale_range"] 

1777 kwargs: ScaleRangeKwargs = Field(default_factory=ScaleRangeKwargs.model_construct) 

1778 

1779 

1780class ScaleMeanVarianceKwargs(KwargsNode): 

1781 """key word arguments for [ScaleMeanVarianceKwargs][]""" 

1782 

1783 reference_tensor: TensorId 

1784 """ID of unprocessed input tensor to match.""" 

1785 

1786 axes: Annotated[Sequence[AxisId] | None, Field(examples=[("batch", "x", "y")])] = ( 

1787 None 

1788 ) 

1789 """The subset of axes to normalize jointly, i.e. axes to reduce to compute mean/std. 

1790 For example to normalize 'batch', 'x' and 'y' jointly in a tensor ('batch', 'channel', 'y', 'x') 

1791 resulting in a tensor of equal shape normalized per channel, specify `axes=('batch', 'x', 'y')`. 

1792 To normalize samples independently, leave out the 'batch' axis. 

1793 Default: Scale all axes jointly.""" 

1794 

1795 eps: Annotated[float, Interval(gt=0, le=0.1)] = 1e-6 

1796 """Epsilon for numeric stability: 

1797 `out = (tensor - mean) / (std + eps) * (ref_std + eps) + ref_mean.`""" 

1798 

1799 

1800class ScaleMeanVarianceDescr(NodeWithExplicitlySetFields): 

1801 """Scale a tensor's data distribution to match another tensor's mean/std. 

1802 `out = (tensor - mean) / (std + eps) * (ref_std + eps) + ref_mean.` 

1803 """ 

1804 

1805 implemented_id: ClassVar[Literal["scale_mean_variance"]] = "scale_mean_variance" 

1806 if TYPE_CHECKING: 

1807 id: Literal["scale_mean_variance"] = "scale_mean_variance" 

1808 else: 

1809 id: Literal["scale_mean_variance"] 

1810 kwargs: ScaleMeanVarianceKwargs 

1811 

1812 

1813PreprocessingDescr = Annotated[ 

1814 Union[ 

1815 BinarizeDescr, 

1816 ClipDescr, 

1817 EnsureDtypeDescr, 

1818 FixedZeroMeanUnitVarianceDescr, 

1819 ScaleLinearDescr, 

1820 ScaleRangeDescr, 

1821 SigmoidDescr, 

1822 SoftmaxDescr, 

1823 ZeroMeanUnitVarianceDescr, 

1824 ], 

1825 Discriminator("id"), 

1826] 

1827PostprocessingDescr = Annotated[ 

1828 Union[ 

1829 BinarizeDescr, 

1830 CellposeFlowDynamicsDescr, 

1831 ClipDescr, 

1832 CustomProcessingDescr, 

1833 EnsureDtypeDescr, 

1834 FixedZeroMeanUnitVarianceDescr, 

1835 ScaleLinearDescr, 

1836 ScaleMeanVarianceDescr, 

1837 ScaleRangeDescr, 

1838 SigmoidDescr, 

1839 SoftmaxDescr, 

1840 StardistPostprocessingDescr, 

1841 ZeroMeanUnitVarianceDescr, 

1842 ], 

1843 Discriminator("id"), 

1844] 

1845 

1846IO_AxisT = TypeVar("IO_AxisT", InputAxis, OutputAxis) 

1847 

1848 

1849class TensorDescrBase(Node, Generic[IO_AxisT]): 

1850 id: TensorId 

1851 """Tensor id. No duplicates are allowed.""" 

1852 

1853 description: Annotated[str, MaxLen(128)] = "" 

1854 """free text description""" 

1855 

1856 axes: NotEmpty[Sequence[IO_AxisT]] 

1857 """tensor axes""" 

1858 

1859 @property 

1860 def shape(self): 

1861 return tuple(a.size for a in self.axes) 

1862 

1863 @field_validator("axes", mode="after", check_fields=False) 

1864 @classmethod 

1865 def _validate_axes(cls, axes: Sequence[AnyAxis]) -> Sequence[AnyAxis]: 

1866 batch_axes = [a for a in axes if a.type == "batch"] 

1867 if len(batch_axes) > 1: 

1868 raise ValueError( 

1869 f"Only one batch axis (per tensor) allowed, but got {batch_axes}" 

1870 ) 

1871 

1872 seen_ids: set[AxisId] = set() 

1873 duplicate_axes_ids: set[AxisId] = set() 

1874 for a in axes: 

1875 (duplicate_axes_ids if a.id in seen_ids else seen_ids).add(a.id) 

1876 

1877 if duplicate_axes_ids: 

1878 raise ValueError(f"Duplicate axis ids: {duplicate_axes_ids}") 

1879 

1880 return axes 

1881 

1882 test_tensor: FAIR[FileDescr_package | None] = None 

1883 """An example tensor to use for testing. 

1884 Using the model with the test input tensors is expected to yield the test output tensors. 

1885 Each test tensor has be a an ndarray in the 

1886 [numpy.lib file format](https://numpy.org/doc/stable/reference/generated/numpy.lib.format.html#module-numpy.lib.format). 

1887 The file extension must be '.npy'.""" 

1888 

1889 sample_tensor: FAIR[FileDescr_package | None] = None 

1890 """A sample tensor to illustrate a possible input/output for the model, 

1891 The sample image primarily serves to inform a human user about an example use case 

1892 and is typically stored as .hdf5, .png or .tiff. 

1893 It has to be readable by the [imageio library](https://imageio.readthedocs.io/en/stable/formats/index.html#supported-formats) 

1894 (numpy's `.npy` format is not supported). 

1895 The image dimensionality has to match the number of axes specified in this tensor description. 

1896 """ 

1897 

1898 @model_validator(mode="after") 

1899 def _validate_sample_tensor(self) -> Self: 

1900 if self.sample_tensor is None or not get_validation_context().perform_io_checks: 

1901 return self 

1902 

1903 reader = get_reader(self.sample_tensor.source, sha256=self.sample_tensor.sha256) 

1904 tensor: NDArray[Any] = imread( # pyright: ignore[reportUnknownVariableType] 

1905 reader.read(), 

1906 extension=PurePosixPath(reader.original_file_name).suffix, 

1907 ) 

1908 n_dims = len(tensor.squeeze().shape) 

1909 n_dims_min = n_dims_max = len(self.axes) 

1910 

1911 for a in self.axes: 

1912 if isinstance(a, BatchAxis): 

1913 n_dims_min -= 1 

1914 elif isinstance(a.size, int): 

1915 if a.size == 1: 

1916 n_dims_min -= 1 

1917 elif isinstance(a.size, (ParameterizedSize, DataDependentSize)): 

1918 if a.size.min == 1: 

1919 n_dims_min -= 1 

1920 elif isinstance(a.size, SizeReference): 

1921 if a.size.offset < 2: 

1922 # size reference may result in singleton axis 

1923 n_dims_min -= 1 

1924 else: 

1925 assert_never(a.size) 

1926 

1927 n_dims_min = max(0, n_dims_min) 

1928 if n_dims < n_dims_min or n_dims > n_dims_max: 

1929 raise ValueError( 

1930 f"Expected sample tensor to have {n_dims_min} to" 

1931 + f" {n_dims_max} dimensions, but found {n_dims} (shape: {tensor.shape})." 

1932 ) 

1933 

1934 return self 

1935 

1936 data: TensorDataDescr | NotEmpty[Sequence[TensorDataDescr]] = ( 

1937 IntervalOrRatioDataDescr() 

1938 ) 

1939 """Description of the tensor's data values, optionally per channel. 

1940 If specified per channel, the data `type` needs to match across channels.""" 

1941 

1942 @property 

1943 def dtype( 

1944 self, 

1945 ) -> Literal[ 

1946 "float32", 

1947 "float64", 

1948 "uint8", 

1949 "int8", 

1950 "uint16", 

1951 "int16", 

1952 "uint32", 

1953 "int32", 

1954 "uint64", 

1955 "int64", 

1956 "bool", 

1957 ]: 

1958 """dtype as specified under `data.type` or `data[i].type`""" 

1959 if isinstance(self.data, collections.abc.Sequence): 

1960 return self.data[0].type 

1961 else: 

1962 return self.data.type 

1963 

1964 @field_validator("data", mode="after") 

1965 @classmethod 

1966 def _check_data_type_across_channels( 

1967 cls, value: TensorDataDescr | NotEmpty[Sequence[TensorDataDescr]] 

1968 ) -> TensorDataDescr | NotEmpty[Sequence[TensorDataDescr]]: 

1969 if not isinstance(value, list): 

1970 return value 

1971 

1972 dtypes = {t.type for t in value} 

1973 if len(dtypes) > 1: 

1974 raise ValueError( 

1975 "Tensor data descriptions per channel need to agree in their data" 

1976 + f" `type`, but found {dtypes}." 

1977 ) 

1978 

1979 return value 

1980 

1981 @model_validator(mode="after") 

1982 def _check_data_matches_channelaxis(self) -> Self: 

1983 if not isinstance(self.data, (list, tuple)): 

1984 return self 

1985 

1986 for a in self.axes: 

1987 if isinstance(a, ChannelAxis): 

1988 size = a.size 

1989 assert isinstance(size, int) 

1990 break 

1991 else: 

1992 return self 

1993 

1994 if len(self.data) != size: 

1995 raise ValueError( 

1996 f"Got tensor data descriptions for {len(self.data)} channels, but" 

1997 + f" '{a.id}' axis has size {size}." 

1998 ) 

1999 

2000 return self 

2001 

2002 def get_axis_sizes_for_array(self, array: NDArray[Any]) -> dict[AxisId, int]: 

2003 if len(array.shape) != len(self.axes): 

2004 raise ValueError( 

2005 f"Dimension mismatch: array shape {array.shape} (#{len(array.shape)})" 

2006 + f" incompatible with {len(self.axes)} axes." 

2007 ) 

2008 return {a.id: array.shape[i] for i, a in enumerate(self.axes)} 

2009 

2010 

2011class ConstantPadding(Node): 

2012 mode: Literal["constant"] = "constant" 

2013 value: int | float = 0 

2014 

2015 

2016class EdgePadding(Node): 

2017 mode: Literal["edge"] = "edge" 

2018 

2019 

2020class ReflectPadding(Node): 

2021 mode: Literal["reflect"] = "reflect" 

2022 

2023 

2024class SymmetricPadding(Node): 

2025 mode: Literal["symmetric"] = "symmetric" 

2026 

2027 

2028Padding = Union[ConstantPadding, EdgePadding, ReflectPadding, SymmetricPadding] 

2029 

2030 

2031class ModelId(ResourceId): 

2032 pass 

2033 

2034 

2035class InputTensorDescr(TensorDescrBase[InputAxis]): 

2036 id: TensorId = TensorId("input") 

2037 """Input tensor id. 

2038 No duplicates are allowed across all inputs and outputs.""" 

2039 

2040 output_of: ModelId | None = None 

2041 """If this input tensor is the output of another model, specify the model id here. 

2042 This model's input id must match the output id of the referenced model. 

2043 """ 

2044 

2045 @model_validator(mode="after") 

2046 def _validate_output_of(self) -> Self: 

2047 if self.output_of is None: 

2048 return self 

2049 

2050 try: 

2051 with get_validation_context().replace(perform_io_checks=False): 

2052 opened_ref_model = open_bioimageio_yaml(self.output_of) 

2053 format_version = opened_ref_model.content["format_version"] 

2054 assert isinstance(format_version, str) 

2055 if format_version.startswith("0.4"): 

2056 ref_model = _ModelDescr04.model_validate(opened_ref_model.content) 

2057 else: 

2058 ref_model = ModelDescr.model_validate(opened_ref_model.content) 

2059 except Exception as e: 

2060 raise ValueError( 

2061 f"Failed to load model '{self.output_of}' referenced under output_of: {e}" 

2062 ) 

2063 

2064 try: 

2065 ref_model_outputs = { 

2066 t.id if isinstance(t, OutputTensorDescr) else TensorId(t.name) 

2067 for t in ref_model.outputs 

2068 } 

2069 except Exception as e: 

2070 raise ValueError( 

2071 f"Failed to read output IDs of model '{self.output_of}' referenced under output_of: {e}" 

2072 ) 

2073 

2074 if self.id not in ref_model_outputs: 

2075 raise ValueError( 

2076 f"Input tensor '{self.id}' is specified as output of model '{self.output_of}', " 

2077 + f"but that model's outputs are {ref_model_outputs}." 

2078 ) 

2079 return self 

2080 

2081 optional: bool = False 

2082 """indicates that this tensor may be `None`""" 

2083 

2084 pad: Padding | None = None 

2085 """Explicitly specify how to pad this input tensor. 

2086 

2087 Use `axes[i].pad` to specify padding width. 

2088 

2089 Note: 

2090 Non-blockwise sample prediction only applies padding for axes with a `pad` specification. 

2091 """ 

2092 

2093 preprocessing: list[PreprocessingDescr] = Field( 

2094 default_factory=cast(Callable[[], List[PreprocessingDescr]], list) 

2095 ) 

2096 """Description of how this input should be preprocessed. 

2097 

2098 notes: 

2099 - If preprocessing does not start with an 'ensure_dtype' entry, it is added 

2100 to ensure an input tensor's data type matches the input tensor's data description. 

2101 - If preprocessing does not end with an 'ensure_dtype' or 'binarize' entry, an 

2102 'ensure_dtype' step is added to ensure preprocessing steps are not unintentionally 

2103 changing the data type. 

2104 """ 

2105 

2106 @model_validator(mode="after") 

2107 def _validate_preprocessing_kwargs(self) -> Self: 

2108 axes_ids = [a.id for a in self.axes] 

2109 for p in self.preprocessing: 

2110 kwargs_axes: Sequence[Any] | None = p.kwargs.get("axes") 

2111 if kwargs_axes is None: 

2112 continue 

2113 

2114 if not isinstance(kwargs_axes, collections.abc.Sequence): 

2115 raise ValueError( 

2116 f"Expected `preprocessing.i.kwargs.axes` to be a sequence, but got {type(kwargs_axes)}" 

2117 ) 

2118 

2119 if any(a not in axes_ids for a in kwargs_axes): 

2120 raise ValueError( 

2121 "`preprocessing.i.kwargs.axes` needs to be subset of axes ids" 

2122 ) 

2123 

2124 if isinstance(self.data, (NominalOrOrdinalDataDescr, IntervalOrRatioDataDescr)): 

2125 dtype = self.data.type 

2126 else: 

2127 dtype = self.data[0].type 

2128 

2129 # ensure `preprocessing` begins with `EnsureDtypeDescr` 

2130 if not self.preprocessing or not isinstance( 

2131 self.preprocessing[0], EnsureDtypeDescr 

2132 ): 

2133 self.preprocessing.insert( 

2134 0, EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype=dtype)) 

2135 ) 

2136 

2137 # ensure `preprocessing` ends with `EnsureDtypeDescr` or `BinarizeDescr` 

2138 if not isinstance(self.preprocessing[-1], (EnsureDtypeDescr, BinarizeDescr)): 

2139 self.preprocessing.append( 

2140 EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype=dtype)) 

2141 ) 

2142 

2143 return self 

2144 

2145 

2146def convert_axes( 

2147 axes: str, 

2148 *, 

2149 shape: Sequence[int] | _ParameterizedInputShape_v0_4 | _ImplicitOutputShape_v0_4, 

2150 tensor_type: Literal["input", "output"], 

2151 halo: Sequence[int] | None, 

2152 size_refs: Mapping[_TensorName_v0_4, Mapping[str, int]], 

2153): 

2154 ret: list[AnyAxis] = [] 

2155 for i, a in enumerate(axes): 

2156 axis_type = _AXIS_TYPE_MAP.get(a, a) 

2157 if axis_type == "batch": 

2158 ret.append(BatchAxis()) 

2159 continue 

2160 

2161 scale = 1.0 

2162 if isinstance(shape, _ParameterizedInputShape_v0_4): 

2163 if shape.step[i] == 0: 

2164 size = shape.min[i] 

2165 else: 

2166 size = ParameterizedSize(min=shape.min[i], step=shape.step[i]) 

2167 elif isinstance(shape, _ImplicitOutputShape_v0_4): 

2168 ref_t = str(shape.reference_tensor) 

2169 if ref_t.count(".") == 1: 

2170 t_id, orig_a_id = ref_t.split(".") 

2171 else: 

2172 t_id = ref_t 

2173 orig_a_id = a 

2174 

2175 a_id = _AXIS_ID_MAP.get(orig_a_id, a) 

2176 if not (orig_scale := shape.scale[i]): 

2177 # old way to insert a new axis dimension 

2178 size = int(2 * shape.offset[i]) 

2179 else: 

2180 scale = 1 / orig_scale 

2181 if axis_type in ("channel", "index"): 

2182 # these axes no longer have a scale 

2183 offset_from_scale = orig_scale * size_refs.get( 

2184 _TensorName_v0_4(t_id), {} 

2185 ).get(orig_a_id, 0) 

2186 else: 

2187 offset_from_scale = 0 

2188 size = SizeReference( 

2189 tensor_id=TensorId(t_id), 

2190 axis_id=AxisId(a_id), 

2191 offset=int(offset_from_scale + 2 * shape.offset[i]), 

2192 ) 

2193 else: 

2194 size = shape[i] 

2195 

2196 if axis_type == "time": 

2197 if tensor_type == "input": 

2198 ret.append(TimeInputAxis(size=size, scale=scale)) 

2199 else: 

2200 assert not isinstance(size, ParameterizedSize) 

2201 if halo is None: 

2202 ret.append(TimeOutputAxis(size=size, scale=scale)) 

2203 else: 

2204 assert not isinstance(size, int) 

2205 ret.append( 

2206 TimeOutputAxisWithHalo(size=size, scale=scale, halo=halo[i]) 

2207 ) 

2208 

2209 elif axis_type == "index": 

2210 if tensor_type == "input": 

2211 ret.append(IndexInputAxis(size=size)) 

2212 else: 

2213 if isinstance(size, ParameterizedSize): 

2214 size = DataDependentSize(min=size.min) 

2215 

2216 ret.append(IndexOutputAxis(size=size)) 

2217 elif axis_type == "channel": 

2218 assert not isinstance(size, ParameterizedSize) 

2219 if isinstance(size, SizeReference): 

2220 warnings.warn( 

2221 "Conversion of channel size from an implicit output shape may be" 

2222 + " wrong" 

2223 ) 

2224 ret.append( 

2225 ChannelAxis( 

2226 channel_names=[f"channel{i}" for i in range(size.offset)] 

2227 ) 

2228 ) 

2229 else: 

2230 ret.append( 

2231 ChannelAxis(channel_names=[f"channel{i}" for i in range(size)]) 

2232 ) 

2233 elif axis_type == "space": 

2234 if tensor_type == "input": 

2235 ret.append(SpaceInputAxis(id=AxisId(a), size=size, scale=scale)) 

2236 else: 

2237 assert not isinstance(size, ParameterizedSize) 

2238 if halo is None or halo[i] == 0: 

2239 ret.append(SpaceOutputAxis(id=AxisId(a), size=size, scale=scale)) 

2240 elif isinstance(size, int): 

2241 raise NotImplementedError( 

2242 f"output axis with halo and fixed size (here {size}) not allowed" 

2243 ) 

2244 else: 

2245 ret.append( 

2246 SpaceOutputAxisWithHalo( 

2247 id=AxisId(a), size=size, scale=scale, halo=halo[i] 

2248 ) 

2249 ) 

2250 

2251 return ret 

2252 

2253 

2254def _axes_letters_to_ids( 

2255 axes: str | None, 

2256) -> list[AxisId] | None: 

2257 if axes is None: 

2258 return None 

2259 

2260 return [AxisId(a) for a in axes] 

2261 

2262 

2263def _get_complement_v04_axis( 

2264 tensor_axes: Sequence[str], axes: Sequence[str] | None 

2265) -> AxisId | None: 

2266 if axes is None: 

2267 return None 

2268 

2269 non_complement_axes = set(axes) | {"b"} 

2270 complement_axes = [a for a in tensor_axes if a not in non_complement_axes] 

2271 if len(complement_axes) > 1: 

2272 raise ValueError( 

2273 f"Expected none or a single complement axis, but axes '{axes}' " 

2274 + f"for tensor dims '{tensor_axes}' leave '{complement_axes}'." 

2275 ) 

2276 

2277 return None if not complement_axes else AxisId(complement_axes[0]) 

2278 

2279 

2280def _convert_proc( 

2281 p: _PreprocessingDescr_v0_4 | _PostprocessingDescr_v0_4, 

2282 tensor_axes: Sequence[str], 

2283) -> PreprocessingDescr | PostprocessingDescr: 

2284 if isinstance(p, _BinarizeDescr_v0_4): 

2285 return BinarizeDescr(kwargs=BinarizeKwargs(threshold=p.kwargs.threshold)) 

2286 elif isinstance(p, _ClipDescr_v0_4): 

2287 return ClipDescr(kwargs=ClipKwargs(min=p.kwargs.min, max=p.kwargs.max)) 

2288 elif isinstance(p, _SigmoidDescr_v0_4): 

2289 return SigmoidDescr() 

2290 elif isinstance(p, _ScaleLinearDescr_v0_4): 

2291 axes = _axes_letters_to_ids(p.kwargs.axes) 

2292 if p.kwargs.axes is None: 

2293 axis = None 

2294 else: 

2295 axis = _get_complement_v04_axis(tensor_axes, p.kwargs.axes) 

2296 

2297 if axis is None: 

2298 assert not isinstance(p.kwargs.gain, list) 

2299 assert not isinstance(p.kwargs.offset, list) 

2300 kwargs = ScaleLinearKwargs(gain=p.kwargs.gain, offset=p.kwargs.offset) 

2301 else: 

2302 kwargs = ScaleLinearAlongAxisKwargs( 

2303 axis=axis, gain=p.kwargs.gain, offset=p.kwargs.offset 

2304 ) 

2305 return ScaleLinearDescr(kwargs=kwargs) 

2306 elif isinstance(p, _ScaleMeanVarianceDescr_v0_4): 

2307 return ScaleMeanVarianceDescr( 

2308 kwargs=ScaleMeanVarianceKwargs( 

2309 axes=_axes_letters_to_ids(p.kwargs.axes), 

2310 reference_tensor=TensorId(str(p.kwargs.reference_tensor)), 

2311 eps=p.kwargs.eps, 

2312 ) 

2313 ) 

2314 elif isinstance(p, _ZeroMeanUnitVarianceDescr_v0_4): 

2315 if p.kwargs.mode == "fixed": 

2316 mean = p.kwargs.mean 

2317 std = p.kwargs.std 

2318 assert mean is not None 

2319 assert std is not None 

2320 

2321 axis = _get_complement_v04_axis(tensor_axes, p.kwargs.axes) 

2322 

2323 if axis is None: 

2324 if isinstance(mean, list): 

2325 raise ValueError("Expected single float value for mean, not <list>") 

2326 if isinstance(std, list): 

2327 raise ValueError("Expected single float value for std, not <list>") 

2328 return FixedZeroMeanUnitVarianceDescr( 

2329 kwargs=FixedZeroMeanUnitVarianceKwargs.model_construct( 

2330 mean=mean, 

2331 std=std, 

2332 ) 

2333 ) 

2334 else: 

2335 if not isinstance(mean, list): 

2336 mean = [float(mean)] 

2337 if not isinstance(std, list): 

2338 std = [float(std)] 

2339 

2340 return FixedZeroMeanUnitVarianceDescr( 

2341 kwargs=FixedZeroMeanUnitVarianceAlongAxisKwargs( 

2342 axis=axis, mean=mean, std=std 

2343 ) 

2344 ) 

2345 

2346 else: 

2347 axes = _axes_letters_to_ids(p.kwargs.axes) or [] 

2348 if p.kwargs.mode == "per_dataset": 

2349 axes = [AxisId("batch")] + axes 

2350 if not axes: 

2351 axes = None 

2352 return ZeroMeanUnitVarianceDescr( 

2353 kwargs=ZeroMeanUnitVarianceKwargs(axes=axes, eps=p.kwargs.eps) 

2354 ) 

2355 

2356 elif isinstance(p, _ScaleRangeDescr_v0_4): 

2357 return ScaleRangeDescr( 

2358 kwargs=ScaleRangeKwargs( 

2359 axes=_axes_letters_to_ids(p.kwargs.axes), 

2360 min_percentile=p.kwargs.min_percentile, 

2361 max_percentile=p.kwargs.max_percentile, 

2362 eps=p.kwargs.eps, 

2363 ) 

2364 ) 

2365 else: 

2366 assert_never(p) 

2367 

2368 

2369class _InputTensorConv( 

2370 Converter[ 

2371 _InputTensorDescr_v0_4, 

2372 InputTensorDescr, 

2373 FileSource, 

2374 Optional[FileSource], 

2375 Mapping[_TensorName_v0_4, Mapping[str, int]], 

2376 ] 

2377): 

2378 def _convert( 

2379 self, 

2380 src: _InputTensorDescr_v0_4, 

2381 tgt: type[InputTensorDescr | dict[str, Any]], 

2382 test_tensor: FileSource, 

2383 sample_tensor: FileSource | None, 

2384 size_refs: Mapping[_TensorName_v0_4, Mapping[str, int]], 

2385 ) -> InputTensorDescr | dict[str, Any]: 

2386 axes: list[InputAxis] = convert_axes( # pyright: ignore[reportAssignmentType] 

2387 src.axes, 

2388 shape=src.shape, 

2389 tensor_type="input", 

2390 halo=None, 

2391 size_refs=size_refs, 

2392 ) 

2393 prep: list[PreprocessingDescr] = [] 

2394 for p in src.preprocessing: 

2395 cp = _convert_proc(p, src.axes) 

2396 assert not isinstance( 

2397 cp, 

2398 ( 

2399 CellposeFlowDynamicsDescr, 

2400 CustomProcessingDescr, 

2401 ScaleMeanVarianceDescr, 

2402 StardistPostprocessingDescr, 

2403 ), 

2404 ) 

2405 prep.append(cp) 

2406 

2407 prep.append(EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype="float32"))) 

2408 

2409 return tgt( 

2410 axes=axes, 

2411 id=TensorId(str(src.name)), 

2412 test_tensor=FileDescr(source=test_tensor), 

2413 sample_tensor=( 

2414 None if sample_tensor is None else FileDescr(source=sample_tensor) 

2415 ), 

2416 data={"type": src.data_type}, # pyright: ignore[reportArgumentType] 

2417 preprocessing=prep, 

2418 ) 

2419 

2420 

2421_input_tensor_conv = _InputTensorConv(_InputTensorDescr_v0_4, InputTensorDescr) 

2422 

2423 

2424class OutputTensorDescr(TensorDescrBase[OutputAxis]): 

2425 id: TensorId = TensorId("output") 

2426 """Output tensor id. 

2427 No duplicates are allowed across all inputs and outputs.""" 

2428 

2429 postprocessing: list[PostprocessingDescr] = Field( 

2430 default_factory=cast(Callable[[], List[PostprocessingDescr]], list) 

2431 ) 

2432 """Description of how this output should be postprocessed. 

2433 

2434 note: `postprocessing` always ends with an 'ensure_dtype' operation. 

2435 If not given this is added to cast to this tensor's `data.type`. 

2436 """ 

2437 

2438 @model_validator(mode="after") 

2439 def _validate_postprocessing_kwargs(self) -> Self: 

2440 axes_ids = [a.id for a in self.axes] 

2441 for p in self.postprocessing: 

2442 kwargs_axes = p.kwargs.get("axes") 

2443 if kwargs_axes is None: 

2444 continue 

2445 

2446 if not isinstance(kwargs_axes, collections.abc.Sequence): 

2447 raise ValueError( 

2448 f"expected `axes` sequence, but got {type(kwargs_axes)}" 

2449 ) 

2450 

2451 kwargs_axes_seq: Sequence[Any] = cast(Sequence[Any], kwargs_axes) 

2452 if any(a not in axes_ids for a in kwargs_axes_seq): 

2453 raise ValueError("`kwargs.axes` needs to be subset of axes ids") 

2454 

2455 if isinstance(self.data, (NominalOrOrdinalDataDescr, IntervalOrRatioDataDescr)): 

2456 dtype = self.data.type 

2457 else: 

2458 dtype = self.data[0].type 

2459 

2460 # ensure `postprocessing` ends with `EnsureDtypeDescr` or `BinarizeDescr` 

2461 if not self.postprocessing or not isinstance( 

2462 self.postprocessing[-1], (EnsureDtypeDescr, BinarizeDescr) 

2463 ): 

2464 self.postprocessing.append( 

2465 EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype=dtype)) 

2466 ) 

2467 return self 

2468 

2469 

2470class _OutputTensorConv( 

2471 Converter[ 

2472 _OutputTensorDescr_v0_4, 

2473 OutputTensorDescr, 

2474 FileSource, 

2475 Optional[FileSource], 

2476 Mapping[_TensorName_v0_4, Mapping[str, int]], 

2477 ] 

2478): 

2479 def _convert( 

2480 self, 

2481 src: _OutputTensorDescr_v0_4, 

2482 tgt: type[OutputTensorDescr | dict[str, Any]], 

2483 test_tensor: FileSource, 

2484 sample_tensor: FileSource | None, 

2485 size_refs: Mapping[_TensorName_v0_4, Mapping[str, int]], 

2486 ) -> OutputTensorDescr | dict[str, Any]: 

2487 # TODO: split convert_axes into convert_output_axes and convert_input_axes 

2488 axes: list[OutputAxis] = convert_axes( # pyright: ignore[reportAssignmentType] 

2489 src.axes, 

2490 shape=src.shape, 

2491 tensor_type="output", 

2492 halo=src.halo, 

2493 size_refs=size_refs, 

2494 ) 

2495 data_descr: dict[str, Any] = {"type": src.data_type} 

2496 if data_descr["type"] == "bool": 

2497 data_descr["values"] = [False, True] 

2498 

2499 return tgt( 

2500 axes=axes, 

2501 id=TensorId(str(src.name)), 

2502 test_tensor=FileDescr(source=test_tensor), 

2503 sample_tensor=( 

2504 None if sample_tensor is None else FileDescr(source=sample_tensor) 

2505 ), 

2506 data=data_descr, # pyright: ignore[reportArgumentType] 

2507 postprocessing=[_convert_proc(p, src.axes) for p in src.postprocessing], 

2508 ) 

2509 

2510 

2511_output_tensor_conv = _OutputTensorConv(_OutputTensorDescr_v0_4, OutputTensorDescr) 

2512 

2513 

2514TensorDescr = Union[InputTensorDescr, OutputTensorDescr] 

2515 

2516 

2517def get_halos( 

2518 tensors: Mapping[TensorId, TensorDescr], 

2519 /, 

2520) -> dict[TensorId, dict[AxisId, tuple[int, int]]]: 

2521 """Get all input and output halos from tensor descriptions. 

2522 

2523 Note: 

2524 - Input halos are to be padded 

2525 - Output halos are to be cropped 

2526 """ 

2527 halos: dict[TensorId, dict[AxisId, tuple[int, int]]] = {} 

2528 for descr in tensors.values(): 

2529 if isinstance(descr, InputTensorDescr): 

2530 continue 

2531 for axis in descr.axes: 

2532 if not isinstance(axis, WithHalo): 

2533 continue 

2534 

2535 ref_scale = next( 

2536 a 

2537 for a in tensors[axis.size.tensor_id].axes 

2538 if a.id == axis.size.axis_id 

2539 ).scale 

2540 

2541 # set output halo (to be cropped) 

2542 halos.setdefault(descr.id, {})[axis.id] = (axis.halo, axis.halo) 

2543 # set input halo (to be padded) 

2544 pad_width = int(axis.halo / axis.scale * ref_scale) 

2545 halos.setdefault(axis.size.tensor_id, {})[axis.size.axis_id] = ( 

2546 pad_width, 

2547 pad_width, 

2548 ) 

2549 

2550 return halos 

2551 

2552 

2553def validate_tensors( 

2554 tensors: Mapping[TensorId, tuple[TensorDescr, NDArray[Any] | None]], 

2555 tensor_origin: Literal[ 

2556 "source", "test_tensor" 

2557 ] = "source", # for more precise error messages 

2558 *, 

2559 pad_inputs: bool | Literal["allow"] = True, 

2560 crop_outputs: bool | Literal["allow"] = True, 

2561): 

2562 """Validate all inputs (and optionally output tensors) against their tensor descriptions. 

2563 

2564 Args: 

2565 tensors: Mapping of tensor id to a tuple of tensor description and optional numpy array. 

2566 tensor_origin: String to use in error messages to indicate the origin of the tensors being validated. 

2567 pad_inputs: Wether to apply/allow padding of inputs before shape comparison 

2568 crop_outputs: Wether to apply/allow cropping of outputs before shape comparison. 

2569 """ 

2570 all_tensor_axes: dict[TensorId, dict[AxisId, tuple[AnyAxis, int | None]]] = {} 

2571 

2572 def e_msg_location(d: TensorDescr): 

2573 return f"{'inputs' if isinstance(d, InputTensorDescr) else 'outputs'}[{d.id}]" 

2574 

2575 for descr, array in tensors.values(): 

2576 if array is None: 

2577 axis_sizes = {a.id: None for a in descr.axes} 

2578 else: 

2579 try: 

2580 axis_sizes = descr.get_axis_sizes_for_array(array) 

2581 except ValueError as e: 

2582 raise ValueError(f"{e_msg_location(descr)} {e}") 

2583 

2584 all_tensor_axes[descr.id] = {a.id: (a, axis_sizes[a.id]) for a in descr.axes} 

2585 

2586 # get halos to be padded/cropped to validate against halo-adjusted sizes 

2587 io_halos = get_halos({k: v[0] for k, v in tensors.items()}) 

2588 

2589 for descr, array in tensors.values(): 

2590 if array is None: 

2591 continue 

2592 

2593 if descr.dtype in ("float32", "float64"): 

2594 invalid_test_tensor_dtype = array.dtype.name not in ( 

2595 "float32", 

2596 "float64", 

2597 "uint8", 

2598 "int8", 

2599 "uint16", 

2600 "int16", 

2601 "uint32", 

2602 "int32", 

2603 "uint64", 

2604 "int64", 

2605 ) 

2606 else: 

2607 invalid_test_tensor_dtype = array.dtype.name != descr.dtype 

2608 

2609 if invalid_test_tensor_dtype: 

2610 raise ValueError( 

2611 f"{tensor_origin} data type '{array.dtype.name}' does not" 

2612 + f" match described {e_msg_location(descr)}.dtype '{descr.dtype}'" 

2613 ) 

2614 

2615 if array.min() > -1e-4 and array.max() < 1e-4: 

2616 raise ValueError( 

2617 "Output values are too small for reliable testing." 

2618 + f" Values <-1e5 or >=1e5 must be present in {tensor_origin}" 

2619 ) 

2620 

2621 for a in descr.axes: 

2622 actual_size = all_tensor_axes[descr.id][a.id][1] 

2623 

2624 if actual_size is None: 

2625 continue 

2626 

2627 if a.size is None: 

2628 continue 

2629 

2630 # add padding width to actual tensor size 

2631 total_axis_halo = sum(io_halos.get(descr.id, {}).get(a.id, (0, 0))) 

2632 if isinstance(descr, InputTensorDescr): 

2633 # pad input halos 

2634 actual_size_with_halo = actual_size + total_axis_halo 

2635 if pad_inputs is True: 

2636 check_sizes = {actual_size_with_halo} 

2637 size_hint = " (after padding input halo)" 

2638 elif pad_inputs == "allow": 

2639 check_sizes = {actual_size, actual_size_with_halo} 

2640 size_hint = " (with or without padding input halo)" 

2641 elif pad_inputs is False: 

2642 check_sizes = {actual_size} 

2643 size_hint = "" 

2644 else: 

2645 assert_never(pad_inputs) 

2646 

2647 elif isinstance(descr, OutputTensorDescr): 

2648 # crop output halos 

2649 actual_size_with_halo = max(0, actual_size - total_axis_halo) 

2650 if crop_outputs is True: 

2651 check_sizes = {actual_size_with_halo} 

2652 size_hint = " (after cropping output halo)" 

2653 elif crop_outputs == "allow": 

2654 check_sizes = {actual_size, actual_size_with_halo} 

2655 size_hint = " (with or without cropping output halo)" 

2656 elif crop_outputs is False: 

2657 check_sizes = {actual_size} 

2658 size_hint = "" 

2659 else: 

2660 assert_never(crop_outputs) 

2661 else: 

2662 assert_never(descr) 

2663 

2664 del actual_size # make sure we explicitly use unchanged or halo-adjusted size from here on 

2665 

2666 if isinstance(a.size, int): 

2667 if a.size not in check_sizes: 

2668 raise ValueError( 

2669 f"{e_msg_location(descr)}.axes[{a.id}]: {tensor_origin} axis " 

2670 + f"has incompatible size {check_sizes}{size_hint}, expected {a.size}" 

2671 ) 

2672 elif isinstance(a.size, (ParameterizedSize, DataDependentSize)): 

2673 _ = try_all_raise_last( 

2674 (partial(a.size.validate_size, s) for s in check_sizes), 

2675 f"{e_msg_location(descr)}.axes[{a.id}]: {tensor_origin} axis ", 

2676 ) 

2677 elif isinstance(a.size, SizeReference): 

2678 ref_tensor_axes = all_tensor_axes.get(a.size.tensor_id) 

2679 if ref_tensor_axes is None: 

2680 raise ValueError( 

2681 f"{e_msg_location(descr)}.axes[{a.id}].size.tensor_id: Unknown tensor" 

2682 + f" reference '{a.size.tensor_id}', available: {list(all_tensor_axes)}" 

2683 ) 

2684 

2685 ref_axis, ref_size = ref_tensor_axes.get(a.size.axis_id, (None, None)) 

2686 if ref_axis is None or ref_size is None: 

2687 raise ValueError( 

2688 f"{e_msg_location(descr)}.axes[{a.id}].size.axis_id: Unknown tensor axis" 

2689 + f" reference '{a.size.tensor_id}.{a.size.axis_id}, available: {list(ref_tensor_axes)}" 

2690 ) 

2691 

2692 if a.unit != ref_axis.unit: 

2693 raise ValueError( 

2694 f"{e_msg_location(descr)}.axes[{a.id}].size: `SizeReference` requires" 

2695 + " axis and reference axis to have the same `unit`, but" 

2696 + f" {a.unit}!={ref_axis.unit}" 

2697 ) 

2698 

2699 if ( 

2700 expected_size := ( 

2701 ref_size * ref_axis.scale / a.scale + a.size.offset 

2702 ) 

2703 ) not in check_sizes: 

2704 raise ValueError( 

2705 f"{e_msg_location(descr)}.{tensor_origin}: axis '{a.id}' of size" 

2706 + f" {check_sizes} invalid for referenced size {ref_size};" 

2707 + f" expected {expected_size}" 

2708 ) 

2709 else: 

2710 assert_never(a.size) 

2711 

2712 

2713FileDescr_dependencies = Annotated[ 

2714 FileDescr_package, 

2715 WithSuffix((".yaml", ".yml"), case_sensitive=True), 

2716 Field(examples=[{"source": "environment.yaml"}]), 

2717] 

2718 

2719 

2720class _ArchitectureCallableDescr(Node): 

2721 callable: Annotated[Identifier, Field(examples=["MyNetworkClass", "get_my_model"])] 

2722 """Identifier of the callable that returns a torch.nn.Module instance.""" 

2723 

2724 kwargs: dict[str, YamlValue] = Field( 

2725 default_factory=cast(Callable[[], Dict[str, YamlValue]], dict) 

2726 ) 

2727 """key word arguments for the `callable`""" 

2728 

2729 

2730class ArchitectureFromFileDescr(_ArchitectureCallableDescr, FileDescr): 

2731 source: Annotated[FileSource, AfterValidator(wo_special_file_name)] 

2732 """Architecture source file""" 

2733 

2734 @model_serializer(mode="wrap", when_used="unless-none") 

2735 def _serialize(self, nxt: SerializerFunctionWrapHandler, info: SerializationInfo): 

2736 return package_file_descr_serializer(self, nxt, info) 

2737 

2738 

2739class ArchitectureFromLibraryDescr(_ArchitectureCallableDescr): 

2740 import_from: str 

2741 """Where to import the callable from, i.e. `from <import_from> import <callable>`""" 

2742 

2743 

2744class _ArchFileConv( 

2745 Converter[ 

2746 _CallableFromFile_v0_4, 

2747 ArchitectureFromFileDescr, 

2748 Optional[Sha256], 

2749 Dict[str, Any], 

2750 ] 

2751): 

2752 def _convert( 

2753 self, 

2754 src: _CallableFromFile_v0_4, 

2755 tgt: type[ArchitectureFromFileDescr | dict[str, Any]], 

2756 sha256: Sha256 | None, 

2757 kwargs: dict[str, Any], 

2758 ) -> ArchitectureFromFileDescr | dict[str, Any]: 

2759 if src.startswith("http") and src.count(":") == 2: 

2760 http, source, callable_ = src.split(":") 

2761 source = f"{http}:{source}" 

2762 elif not src.startswith("http") and src.count(":") == 1: 

2763 source, callable_ = src.split(":") 

2764 else: 

2765 source = str(src) 

2766 callable_ = str(src) 

2767 return tgt( 

2768 callable=Identifier(callable_), 

2769 source=cast(FileSource, source), 

2770 sha256=sha256, 

2771 kwargs=kwargs, 

2772 ) 

2773 

2774 

2775_arch_file_conv = _ArchFileConv(_CallableFromFile_v0_4, ArchitectureFromFileDescr) 

2776 

2777 

2778class _ArchLibConv( 

2779 Converter[ 

2780 _CallableFromDepencency_v0_4, ArchitectureFromLibraryDescr, Dict[str, Any] 

2781 ] 

2782): 

2783 def _convert( 

2784 self, 

2785 src: _CallableFromDepencency_v0_4, 

2786 tgt: type[ArchitectureFromLibraryDescr | dict[str, Any]], 

2787 kwargs: dict[str, Any], 

2788 ) -> ArchitectureFromLibraryDescr | dict[str, Any]: 

2789 *mods, callable_ = src.split(".") 

2790 import_from = ".".join(mods) 

2791 return tgt( 

2792 import_from=import_from, callable=Identifier(callable_), kwargs=kwargs 

2793 ) 

2794 

2795 

2796_arch_lib_conv = _ArchLibConv( 

2797 _CallableFromDepencency_v0_4, ArchitectureFromLibraryDescr 

2798) 

2799 

2800 

2801class WeightsEntryDescrBase(FileDescr): 

2802 type: ClassVar[WeightsFormat] 

2803 weights_format_name: ClassVar[str] # human readable 

2804 

2805 source: Annotated[FileSource, AfterValidator(wo_special_file_name)] 

2806 """Source of the weights file.""" 

2807 

2808 authors: list[Author] | None = None 

2809 """Authors 

2810 Either the person(s) that have trained this model resulting in the original weights file. 

2811 (If this is the initial weights entry, i.e. it does not have a `parent`) 

2812 Or the person(s) who have converted the weights to this weights format. 

2813 (If this is a child weight, i.e. it has a `parent` field) 

2814 """ 

2815 

2816 parent: Annotated[WeightsFormat | None, Field(examples=["pytorch_state_dict"])] = ( 

2817 None 

2818 ) 

2819 """The source weights these weights were converted from. 

2820 For example, if a model's weights were converted from the `pytorch_state_dict` format to `torchscript`, 

2821 The `pytorch_state_dict` weights entry has no `parent` and is the parent of the `torchscript` weights. 

2822 All weight entries except one (the initial set of weights resulting from training the model), 

2823 need to have this field.""" 

2824 

2825 comment: str = "" 

2826 """A comment about this weights entry, for example how these weights were created.""" 

2827 

2828 @model_validator(mode="after") 

2829 def _validate(self) -> Self: 

2830 if self.type == self.parent: 

2831 raise ValueError("Weights entry can't be it's own parent.") 

2832 

2833 return self 

2834 

2835 @model_serializer(mode="wrap", when_used="unless-none") 

2836 def _serialize(self, nxt: SerializerFunctionWrapHandler, info: SerializationInfo): 

2837 return package_file_descr_serializer(self, nxt, info) 

2838 

2839 

2840class KerasHdf5WeightsDescr(WeightsEntryDescrBase): 

2841 type: ClassVar[WeightsFormat] = "keras_hdf5" 

2842 weights_format_name: ClassVar[str] = "Keras HDF5" 

2843 tensorflow_version: Version 

2844 """TensorFlow version used to create these weights.""" 

2845 

2846 

2847class KerasV3WeightsDescr(WeightsEntryDescrBase): 

2848 type: ClassVar[WeightsFormat] = "keras_v3" 

2849 weights_format_name: ClassVar[str] = "Keras v3" 

2850 keras_version: Annotated[Version, Ge(Version(3))] 

2851 """Keras version used to create these weights.""" 

2852 backend: tuple[Literal["tensorflow", "jax", "torch"], Version] 

2853 """Keras backend used to create these weights.""" 

2854 source: Annotated[ 

2855 FileSource, 

2856 AfterValidator(wo_special_file_name), 

2857 WithSuffix(".keras", case_sensitive=True), 

2858 ] 

2859 """Source of the .keras weights file.""" 

2860 

2861 

2862FileDescr_external_data = Annotated[ 

2863 FileDescr_package, 

2864 WithSuffix(".data", case_sensitive=True), 

2865 Field(examples=[{"source": "weights.onnx.data"}]), 

2866] 

2867 

2868 

2869class OnnxWeightsDescr(WeightsEntryDescrBase): 

2870 type: ClassVar[WeightsFormat] = "onnx" 

2871 weights_format_name: ClassVar[str] = "ONNX" 

2872 opset_version: Annotated[int, Ge(7)] 

2873 """ONNX opset version""" 

2874 

2875 external_data: FileDescr_external_data | None = None 

2876 """Source of the external ONNX data file holding the weights. 

2877 (If present **source** holds the ONNX architecture without weights).""" 

2878 

2879 @model_validator(mode="after") 

2880 def _validate_external_data_unique_file_name(self) -> Self: 

2881 if self.external_data is not None and ( 

2882 extract_file_name(self.source) 

2883 == extract_file_name(self.external_data.source) 

2884 ): 

2885 raise ValueError( 

2886 f"ONNX `external_data` file name '{extract_file_name(self.external_data.source)}'" 

2887 + " must be different from ONNX `source` file name." 

2888 ) 

2889 

2890 return self 

2891 

2892 

2893class PytorchStateDictWeightsDescr(WeightsEntryDescrBase): 

2894 type: ClassVar[WeightsFormat] = "pytorch_state_dict" 

2895 weights_format_name: ClassVar[str] = "Pytorch State Dict" 

2896 architecture: ArchitectureFromFileDescr | ArchitectureFromLibraryDescr 

2897 pytorch_version: Version 

2898 """Version of the PyTorch library used. 

2899 If `architecture.depencencies` is specified it has to include pytorch and any version pinning has to be compatible. 

2900 """ 

2901 dependencies: FileDescr_dependencies | None = None 

2902 """Custom depencies beyond pytorch described in a Conda environment file. 

2903 Allows to specify custom dependencies, see conda docs: 

2904 - [Exporting an environment file across platforms](https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#exporting-an-environment-file-across-platforms) 

2905 - [Creating an environment file manually](https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#creating-an-environment-file-manually) 

2906 

2907 The conda environment file should include pytorch and any version pinning has to be compatible with 

2908 **pytorch_version**. 

2909 """ 

2910 strict: bool = True 

2911 """Whether to allow missing or unexpected keys or to be strict about the architecture matching the state dict weights.""" 

2912 

2913 

2914class TensorflowJsWeightsDescr(WeightsEntryDescrBase): 

2915 type: ClassVar[WeightsFormat] = "tensorflow_js" 

2916 weights_format_name: ClassVar[str] = "Tensorflow.js" 

2917 tensorflow_version: Version 

2918 """Version of the TensorFlow library used.""" 

2919 

2920 source: Annotated[FileSource, AfterValidator(wo_special_file_name)] 

2921 """The multi-file weights. 

2922 All required files/folders should be a zip archive.""" 

2923 

2924 

2925class TensorflowSavedModelBundleWeightsDescr(WeightsEntryDescrBase): 

2926 type: ClassVar[WeightsFormat] = "tensorflow_saved_model_bundle" 

2927 weights_format_name: ClassVar[str] = "Tensorflow Saved Model" 

2928 tensorflow_version: Version 

2929 """Version of the TensorFlow library used.""" 

2930 

2931 dependencies: FileDescr_dependencies | None = None 

2932 """Custom dependencies beyond tensorflow. 

2933 Should include tensorflow and any version pinning has to be compatible with **tensorflow_version**.""" 

2934 

2935 source: Annotated[FileSource, AfterValidator(wo_special_file_name)] 

2936 """The multi-file weights. 

2937 All required files/folders should be a zip archive.""" 

2938 

2939 

2940class TorchscriptWeightsDescr(WeightsEntryDescrBase): 

2941 type: ClassVar[WeightsFormat] = "torchscript" 

2942 weights_format_name: ClassVar[str] = "TorchScript" 

2943 pytorch_version: Version 

2944 """Version of the PyTorch library used.""" 

2945 

2946 

2947SpecificWeightsDescr = Union[ 

2948 KerasHdf5WeightsDescr, 

2949 KerasV3WeightsDescr, 

2950 OnnxWeightsDescr, 

2951 PytorchStateDictWeightsDescr, 

2952 TensorflowJsWeightsDescr, 

2953 TensorflowSavedModelBundleWeightsDescr, 

2954 TorchscriptWeightsDescr, 

2955] 

2956 

2957 

2958class WeightsDescr(Node): 

2959 keras_hdf5: KerasHdf5WeightsDescr | None = None 

2960 keras_v3: KerasV3WeightsDescr | None = None 

2961 onnx: OnnxWeightsDescr | None = None 

2962 pytorch_state_dict: PytorchStateDictWeightsDescr | None = None 

2963 tensorflow_js: TensorflowJsWeightsDescr | None = None 

2964 tensorflow_saved_model_bundle: TensorflowSavedModelBundleWeightsDescr | None = None 

2965 torchscript: TorchscriptWeightsDescr | None = None 

2966 

2967 @model_validator(mode="after") 

2968 def check_entries(self) -> Self: 

2969 entries = {wtype for wtype, entry in self if entry is not None} 

2970 

2971 if not entries: 

2972 raise ValueError("Missing weights entry") 

2973 

2974 entries_wo_parent = { 

2975 wtype 

2976 for wtype, entry in self 

2977 if entry is not None and hasattr(entry, "parent") and entry.parent is None 

2978 } 

2979 if len(entries_wo_parent) != 1: 

2980 issue_warning( 

2981 "Exactly one weights entry may not specify the `parent` field (got" 

2982 + " {value}). That entry is considered the original set of model weights." 

2983 + " Other weight formats are created through conversion of the orignal or" 

2984 + " already converted weights. They have to reference the weights format" 

2985 + " they were converted from as their `parent`.", 

2986 value=len(entries_wo_parent), 

2987 field="weights", 

2988 ) 

2989 

2990 for wtype, entry in self: 

2991 if entry is None: 

2992 continue 

2993 

2994 assert hasattr(entry, "type") 

2995 assert hasattr(entry, "parent") 

2996 assert wtype == entry.type 

2997 if ( 

2998 entry.parent is not None and entry.parent not in entries 

2999 ): # self reference checked for `parent` field 

3000 raise ValueError( 

3001 f"`weights.{wtype}.parent={entry.parent} not in specified weight" 

3002 + f" formats: {entries}" 

3003 ) 

3004 

3005 return self 

3006 

3007 def __getitem__( 

3008 self, 

3009 key: WeightsFormat, 

3010 ): 

3011 if key == "keras_hdf5": 

3012 ret = self.keras_hdf5 

3013 elif key == "keras_v3": 

3014 ret = self.keras_v3 

3015 elif key == "onnx": 

3016 ret = self.onnx 

3017 elif key == "pytorch_state_dict": 

3018 ret = self.pytorch_state_dict 

3019 elif key == "tensorflow_js": 

3020 ret = self.tensorflow_js 

3021 elif key == "tensorflow_saved_model_bundle": 

3022 ret = self.tensorflow_saved_model_bundle 

3023 elif key == "torchscript": 

3024 ret = self.torchscript 

3025 else: 

3026 raise KeyError(key) 

3027 

3028 if ret is None: 

3029 raise KeyError(key) 

3030 

3031 return ret 

3032 

3033 @overload 

3034 def __setitem__( 

3035 self, key: Literal["keras_hdf5"], value: KerasHdf5WeightsDescr | None 

3036 ) -> None: ... 

3037 @overload 

3038 def __setitem__( 

3039 self, key: Literal["keras_v3"], value: KerasV3WeightsDescr | None 

3040 ) -> None: ... 

3041 @overload 

3042 def __setitem__( 

3043 self, key: Literal["onnx"], value: OnnxWeightsDescr | None 

3044 ) -> None: ... 

3045 @overload 

3046 def __setitem__( 

3047 self, 

3048 key: Literal["pytorch_state_dict"], 

3049 value: PytorchStateDictWeightsDescr | None, 

3050 ) -> None: ... 

3051 @overload 

3052 def __setitem__( 

3053 self, key: Literal["tensorflow_js"], value: TensorflowJsWeightsDescr | None 

3054 ) -> None: ... 

3055 @overload 

3056 def __setitem__( 

3057 self, 

3058 key: Literal["tensorflow_saved_model_bundle"], 

3059 value: TensorflowSavedModelBundleWeightsDescr | None, 

3060 ) -> None: ... 

3061 @overload 

3062 def __setitem__( 

3063 self, key: Literal["torchscript"], value: TorchscriptWeightsDescr | None 

3064 ) -> None: ... 

3065 

3066 def __setitem__( 

3067 self, 

3068 key: WeightsFormat, 

3069 value: SpecificWeightsDescr | None, 

3070 ): 

3071 if key == "keras_hdf5": 

3072 if value is not None and not isinstance(value, KerasHdf5WeightsDescr): 

3073 raise TypeError( 

3074 f"Expected KerasHdf5WeightsDescr or None for key 'keras_hdf5', got {type(value)}" 

3075 ) 

3076 self.keras_hdf5 = value 

3077 elif key == "keras_v3": 

3078 if value is not None and not isinstance(value, KerasV3WeightsDescr): 

3079 raise TypeError( 

3080 f"Expected KerasV3WeightsDescr or None for key 'keras_v3', got {type(value)}" 

3081 ) 

3082 self.keras_v3 = value 

3083 elif key == "onnx": 

3084 if value is not None and not isinstance(value, OnnxWeightsDescr): 

3085 raise TypeError( 

3086 f"Expected OnnxWeightsDescr or None for key 'onnx', got {type(value)}" 

3087 ) 

3088 self.onnx = value 

3089 elif key == "pytorch_state_dict": 

3090 if value is not None and not isinstance( 

3091 value, PytorchStateDictWeightsDescr 

3092 ): 

3093 raise TypeError( 

3094 f"Expected PytorchStateDictWeightsDescr or None for key 'pytorch_state_dict', got {type(value)}" 

3095 ) 

3096 self.pytorch_state_dict = value 

3097 elif key == "tensorflow_js": 

3098 if value is not None and not isinstance(value, TensorflowJsWeightsDescr): 

3099 raise TypeError( 

3100 f"Expected TensorflowJsWeightsDescr or None for key 'tensorflow_js', got {type(value)}" 

3101 ) 

3102 self.tensorflow_js = value 

3103 elif key == "tensorflow_saved_model_bundle": 

3104 if value is not None and not isinstance( 

3105 value, TensorflowSavedModelBundleWeightsDescr 

3106 ): 

3107 raise TypeError( 

3108 f"Expected TensorflowSavedModelBundleWeightsDescr or None for key 'tensorflow_saved_model_bundle', got {type(value)}" 

3109 ) 

3110 self.tensorflow_saved_model_bundle = value 

3111 elif key == "torchscript": 

3112 if value is not None and not isinstance(value, TorchscriptWeightsDescr): 

3113 raise TypeError( 

3114 f"Expected TorchscriptWeightsDescr or None for key 'torchscript', got {type(value)}" 

3115 ) 

3116 self.torchscript = value 

3117 else: 

3118 raise KeyError(key) 

3119 

3120 @property 

3121 def available_formats(self) -> dict[WeightsFormat, SpecificWeightsDescr]: 

3122 return { 

3123 **({} if self.keras_hdf5 is None else {"keras_hdf5": self.keras_hdf5}), 

3124 **({} if self.keras_v3 is None else {"keras_v3": self.keras_v3}), 

3125 **({} if self.onnx is None else {"onnx": self.onnx}), 

3126 **( 

3127 {} 

3128 if self.pytorch_state_dict is None 

3129 else {"pytorch_state_dict": self.pytorch_state_dict} 

3130 ), 

3131 **( 

3132 {} 

3133 if self.tensorflow_js is None 

3134 else {"tensorflow_js": self.tensorflow_js} 

3135 ), 

3136 **( 

3137 {} 

3138 if self.tensorflow_saved_model_bundle is None 

3139 else { 

3140 "tensorflow_saved_model_bundle": self.tensorflow_saved_model_bundle 

3141 } 

3142 ), 

3143 **({} if self.torchscript is None else {"torchscript": self.torchscript}), 

3144 } 

3145 

3146 @property 

3147 def missing_formats(self) -> set[WeightsFormat]: 

3148 return { 

3149 wf for wf in get_args(WeightsFormat) if wf not in self.available_formats 

3150 } 

3151 

3152 

3153class LinkedModel(LinkedResourceBase): 

3154 """Reference to a bioimage.io model.""" 

3155 

3156 id: ModelId 

3157 """A valid model `id` from the bioimage.io collection.""" 

3158 

3159 

3160class _DataDepSize(NamedTuple): 

3161 min: StrictInt 

3162 max: StrictInt | None 

3163 

3164 

3165class _AxisSizes(NamedTuple): 

3166 """the lenghts of all axes of model inputs and outputs""" 

3167 

3168 inputs: dict[tuple[TensorId, AxisId], int] 

3169 outputs: dict[tuple[TensorId, AxisId], int | _DataDepSize] 

3170 

3171 

3172class _TensorSizes(NamedTuple): 

3173 """_AxisSizes as nested dicts""" 

3174 

3175 inputs: dict[TensorId, dict[AxisId, int]] 

3176 outputs: dict[TensorId, dict[AxisId, int | _DataDepSize]] 

3177 

3178 

3179class ReproducibilityTolerance(Node, extra="allow"): 

3180 """Describes what small numerical differences -- if any -- may be tolerated 

3181 in the generated output when executing in different environments. 

3182 

3183 A tensor element *output* is considered mismatched to the **test_tensor** if 

3184 abs(*output* - **test_tensor**) > **absolute_tolerance** + **relative_tolerance** * abs(**test_tensor**). 

3185 (Internally we call [numpy.testing.assert_allclose](https://numpy.org/doc/stable/reference/generated/numpy.testing.assert_allclose.html).) 

3186 

3187 Motivation: 

3188 For testing we can request the respective deep learning frameworks to be as 

3189 reproducible as possible by setting seeds and chosing deterministic algorithms, 

3190 but differences in operating systems, available hardware and installed drivers 

3191 may still lead to numerical differences. 

3192 """ 

3193 

3194 relative_tolerance: RelativeTolerance = 1e-3 

3195 """Maximum relative tolerance of reproduced test tensor.""" 

3196 

3197 absolute_tolerance: AbsoluteTolerance = 1e-3 

3198 """Maximum absolute tolerance of reproduced test tensor.""" 

3199 

3200 mismatched_elements_per_million: MismatchedElementsPerMillion = 100 

3201 """Maximum number of mismatched elements/pixels per million to tolerate.""" 

3202 

3203 output_ids: Sequence[TensorId] = () 

3204 """Limits the output tensor IDs these reproducibility details apply to.""" 

3205 

3206 weights_formats: Sequence[WeightsFormat] = () 

3207 """Limits the weights formats these details apply to.""" 

3208 

3209 

3210class BiasRisksLimitations(Node, extra="allow"): 

3211 """Known biases, risks, technical limitations, and recommendations for model use.""" 

3212 

3213 known_biases: str = dedent("""\ 

3214 In general bioimage models may suffer from biases caused by: 

3215 

3216 - Imaging protocol dependencies 

3217 - Use of a specific cell type 

3218 - Species-specific training data limitations 

3219 

3220 """) 

3221 """Biases in training data or model behavior.""" 

3222 

3223 risks: str = dedent("""\ 

3224 Common risks in bioimage analysis include: 

3225 

3226 - Erroneously assuming generalization to unseen experimental conditions 

3227 - Trusting (overconfident) model outputs without validation 

3228 - Misinterpretation of results 

3229 

3230 """) 

3231 """Potential risks in the context of bioimage analysis.""" 

3232 

3233 limitations: str | None = None 

3234 """Technical limitations and failure modes.""" 

3235 

3236 recommendations: str = "Users (both direct and downstream) should be made aware of the risks, biases and limitations of the model." 

3237 """Mitigation strategies regarding `known_biases`, `risks`, and `limitations`, as well as applicable best practices. 

3238 

3239 Consider: 

3240 - How to use a validation dataset? 

3241 - How to manually validate? 

3242 - Feasibility of domain adaptation for different experimental setups? 

3243 

3244 """ 

3245 

3246 def format_md(self) -> str: 

3247 if self.limitations is None: 

3248 limitations_header = "" 

3249 else: 

3250 limitations_header = "## Limitations\n\n" 

3251 

3252 return f"""# Bias, Risks, and Limitations 

3253 

3254{self.known_biases} 

3255 

3256{self.risks} 

3257 

3258{limitations_header}{self.limitations or ""} 

3259 

3260## Recommendations 

3261 

3262{self.recommendations} 

3263 

3264""" 

3265 

3266 

3267class TrainingDetails(Node, extra="allow"): 

3268 training_preprocessing: str | None = None 

3269 """Detailed image preprocessing steps during model training: 

3270 

3271 Mention: 

3272 - *Normalization methods* 

3273 - *Augmentation strategies* 

3274 - *Resizing/resampling procedures* 

3275 - *Artifact handling* 

3276 

3277 """ 

3278 

3279 training_epochs: float | None = None 

3280 """Number of training epochs.""" 

3281 

3282 training_batch_size: float | None = None 

3283 """Batch size used in training.""" 

3284 

3285 initial_learning_rate: float | None = None 

3286 """Initial learning rate used in training.""" 

3287 

3288 learning_rate_schedule: str | None = None 

3289 """Learning rate schedule used in training.""" 

3290 

3291 loss_function: str | None = None 

3292 """Loss function used in training, e.g. nn.MSELoss.""" 

3293 

3294 loss_function_kwargs: dict[str, YamlValue] = Field( 

3295 default_factory=cast(Callable[[], Dict[str, YamlValue]], dict) 

3296 ) 

3297 """key word arguments for the `loss_function`""" 

3298 

3299 optimizer: str | None = None 

3300 """optimizer, e.g. torch.optim.Adam""" 

3301 

3302 optimizer_kwargs: dict[str, YamlValue] = Field( 

3303 default_factory=cast(Callable[[], Dict[str, YamlValue]], dict) 

3304 ) 

3305 """key word arguments for the `optimizer`""" 

3306 

3307 regularization: str | None = None 

3308 """Regularization techniques used during training, e.g. drop-out or weight decay.""" 

3309 

3310 training_duration: float | None = None 

3311 """Total training duration in hours.""" 

3312 

3313 

3314class Evaluation(Node, extra="allow"): 

3315 model_id: ModelId | None = None 

3316 """Model being evaluated.""" 

3317 

3318 dataset_id: DatasetId 

3319 """Dataset used for evaluation.""" 

3320 

3321 dataset_source: HttpUrl 

3322 """Source of the dataset.""" 

3323 

3324 dataset_role: Literal["train", "validation", "test", "independent", "unknown"] 

3325 """Role of the dataset used for evaluation. 

3326 

3327 - `train`: dataset was (part of) the training data 

3328 - `validation`: dataset was (part of) the validation data used during training, e.g. used for model selection or hyperparameter tuning 

3329 - `test`: dataset was (part of) the designated test data; not used during training or validation, but acquired from the same source/distribution as training data 

3330 - `independent`: dataset is entirely independent test data; not used during training or validation, and acquired from a different source/distribution than training data 

3331 - `unknown`: role of the dataset is unknown; choose this if you are not certain if (a subset) of the data was seen by the model during training. 

3332 """ 

3333 

3334 sample_count: int 

3335 """Number of evaluated samples.""" 

3336 

3337 evaluation_factors: list[Annotated[str, MaxLen(16)]] 

3338 """(Abbreviations of) each evaluation factor. 

3339 

3340 Evaluation factors are criteria along which model performance is evaluated, e.g. different image conditions 

3341 like 'low SNR', 'high cell density', or different biological conditions like 'cell type A', 'cell type B'. 

3342 An 'overall' factor may be included to summarize performance across all conditions. 

3343 """ 

3344 

3345 evaluation_factors_long: list[str] 

3346 """Descriptions (long form) of each evaluation factor.""" 

3347 

3348 metrics: list[Annotated[str, MaxLen(16)]] 

3349 """(Abbreviations of) metrics used for evaluation.""" 

3350 

3351 metrics_long: list[str] 

3352 """Description of each metric used.""" 

3353 

3354 @model_validator(mode="after") 

3355 def _validate_list_lengths(self) -> Self: 

3356 if len(self.evaluation_factors) != len(self.evaluation_factors_long): 

3357 raise ValueError( 

3358 "`evaluation_factors` and `evaluation_factors_long` must have the same length" 

3359 ) 

3360 

3361 if len(self.metrics) != len(self.metrics_long): 

3362 raise ValueError("`metrics` and `metrics_long` must have the same length") 

3363 

3364 if len(self.results) != len(self.metrics): 

3365 raise ValueError("`results` must have the same number of rows as `metrics`") 

3366 

3367 for row in self.results: 

3368 if len(row) != len(self.evaluation_factors): 

3369 raise ValueError( 

3370 "`results` must have the same number of columns (in every row) as `evaluation_factors`" 

3371 ) 

3372 

3373 return self 

3374 

3375 results: list[list[str | float | int]] 

3376 """Results for each metric (rows; outer list) and each evaluation factor (columns; inner list).""" 

3377 

3378 results_summary: str | None = None 

3379 """Interpretation of results for general audience. 

3380 

3381 Consider: 

3382 - Overall model performance 

3383 - Comparison to existing methods 

3384 - Limitations and areas for improvement 

3385 

3386""" 

3387 

3388 def format_md(self): 

3389 results_header = ["Metric"] + self.evaluation_factors 

3390 results_table_cells = [results_header, ["---"] * len(results_header)] + [ 

3391 [metric] + [str(r) for r in row] 

3392 for metric, row in zip(self.metrics, self.results) 

3393 ] 

3394 

3395 results_table = "".join( 

3396 "| " + " | ".join(row) + " |\n" for row in results_table_cells 

3397 ) 

3398 factors = "".join( 

3399 f"\n - {ef}: {efl}" 

3400 for ef, efl in zip(self.evaluation_factors, self.evaluation_factors_long) 

3401 ) 

3402 metrics = "".join( 

3403 f"\n - {em}: {eml}" for em, eml in zip(self.metrics, self.metrics_long) 

3404 ) 

3405 

3406 return f"""## Testing Data, Factors & Metrics 

3407 

3408Evaluation of {self.model_id or "this"} model on the {self.dataset_id} dataset (dataset role: {self.dataset_role}). 

3409 

3410### Testing Data 

3411 

3412- **Source:** [{self.dataset_id}]({self.dataset_source}) 

3413- **Size:** {self.sample_count} evaluated samples 

3414 

3415### Factors 

3416{factors} 

3417 

3418### Metrics 

3419{metrics} 

3420 

3421## Results 

3422 

3423### Quantitative Results 

3424 

3425{results_table} 

3426 

3427### Summary 

3428 

3429{self.results_summary or "missing"} 

3430 

3431""" 

3432 

3433 

3434class EnvironmentalImpact(Node, extra="allow"): 

3435 """Environmental considerations for model training and deployment. 

3436 

3437 Carbon emissions can be estimated using the [Machine Learning Impact calculator](https://mlco2.github.io/impact#compute) presented in [Lacoste et al. (2019)](https://arxiv.org/abs/1910.09700). 

3438 """ 

3439 

3440 hardware_type: str | None = None 

3441 """GPU/CPU specifications""" 

3442 

3443 hours_used: float | None = None 

3444 """Total compute hours""" 

3445 

3446 cloud_provider: str | None = None 

3447 """If applicable""" 

3448 

3449 compute_region: str | None = None 

3450 """Geographic location""" 

3451 

3452 co2_emitted: float | None = None 

3453 """kg CO2 equivalent 

3454 

3455 Carbon emissions can be estimated using the [Machine Learning Impact calculator](https://mlco2.github.io/impact#compute) presented in [Lacoste et al. (2019)](https://arxiv.org/abs/1910.09700). 

3456 """ 

3457 

3458 def format_md(self): 

3459 """Filled Markdown template section following [Hugging Face Model Card Template](https://huggingface.co/docs/hub/en/model-card-annotated).""" 

3460 if self == self.__class__(): 

3461 return "" 

3462 

3463 ret = "# Environmental Impact\n\n" 

3464 if self.hardware_type is not None: 

3465 ret += f"- **Hardware Type:** {self.hardware_type}\n" 

3466 if self.hours_used is not None: 

3467 ret += f"- **Hours used:** {self.hours_used}\n" 

3468 if self.cloud_provider is not None: 

3469 ret += f"- **Cloud Provider:** {self.cloud_provider}\n" 

3470 if self.compute_region is not None: 

3471 ret += f"- **Compute Region:** {self.compute_region}\n" 

3472 if self.co2_emitted is not None: 

3473 ret += f"- **Carbon Emitted:** {self.co2_emitted} kg CO2e\n" 

3474 

3475 return ret + "\n" 

3476 

3477 

3478class BioimageioConfig(Node, extra="allow"): 

3479 reproducibility_tolerance: Sequence[ReproducibilityTolerance] = () 

3480 """Tolerances to allow when reproducing the model's test outputs 

3481 from the model's test inputs. 

3482 Only the first entry matching tensor id and weights format is considered. 

3483 """ 

3484 

3485 funded_by: str | None = None 

3486 """Funding agency, grant number if applicable""" 

3487 

3488 architecture_type: Annotated[str, MaxLen(32)] | None = ( 

3489 None # TODO: add to differentiated tags 

3490 ) 

3491 """Model architecture type, e.g., 3D U-Net, ResNet, transformer""" 

3492 

3493 architecture_description: str | None = None 

3494 """Text description of model architecture.""" 

3495 

3496 modality: str | None = None # TODO: add to differentiated tags 

3497 """Input modality, e.g., fluorescence microscopy, electron microscopy""" 

3498 

3499 target_structure: list[str] = Field( # TODO: add to differentiated tags 

3500 default_factory=cast(Callable[[], List[str]], list) 

3501 ) 

3502 """Biological structure(s) the model is designed to analyze, e.g., nuclei, mitochondria, cells""" 

3503 

3504 task: str | None = None # TODO: add to differentiated tags 

3505 """Bioimage-specific task type, e.g., segmentation, classification, detection, denoising""" 

3506 

3507 new_version: ModelId | None = None 

3508 """A new version of this model exists with a different model id.""" 

3509 

3510 out_of_scope_use: str | None = None 

3511 """Describe how the model may be misused in bioimage analysis contexts and what users should **not** do with the model.""" 

3512 

3513 bias_risks_limitations: BiasRisksLimitations = Field( 

3514 default_factory=BiasRisksLimitations.model_construct 

3515 ) 

3516 """Description of known bias, risks, and technical limitations for in-scope model use.""" 

3517 

3518 model_parameter_count: int | None = None 

3519 """Total number of model parameters.""" 

3520 

3521 training: TrainingDetails = Field(default_factory=TrainingDetails.model_construct) 

3522 """Details on how the model was trained.""" 

3523 

3524 inference_time: str | None = None 

3525 """Average inference time per image/tile. Specify hardware and image size. Multiple examples can be given.""" 

3526 

3527 memory_requirements_inference: str | None = None 

3528 """GPU memory needed for inference. Multiple examples with different image size can be given.""" 

3529 

3530 memory_requirements_training: str | None = None 

3531 """GPU memory needed for training. Multiple examples with different image/batch sizes can be given.""" 

3532 

3533 evaluations: list[Evaluation] = Field( 

3534 default_factory=cast(Callable[[], List[Evaluation]], list) 

3535 ) 

3536 """Quantitative model evaluations. 

3537 

3538 Note: 

3539 At the moment we recommend to include only a single test dataset 

3540 (with evaluation factors that may mark subsets of the dataset) 

3541 to avoid confusion and make the presentation of results cleaner. 

3542 """ 

3543 

3544 environmental_impact: EnvironmentalImpact = Field( 

3545 default_factory=EnvironmentalImpact.model_construct 

3546 ) 

3547 """Environmental considerations for model training and deployment""" 

3548 

3549 

3550class Config(Node, extra="allow"): 

3551 bioimageio: BioimageioConfig = Field( 

3552 default_factory=BioimageioConfig.model_construct 

3553 ) 

3554 stardist: YamlValue = None 

3555 

3556 

3557class ModelDescr(GenericModelDescrBase): 

3558 """Specification of the fields used in a bioimage.io-compliant RDF to describe AI models with pretrained weights. 

3559 These fields are typically stored in a YAML file which we call a model resource description file (model RDF). 

3560 """ 

3561 

3562 implemented_format_version: ClassVar[Literal["0.5.13"]] = "0.5.13" 

3563 if TYPE_CHECKING: 

3564 format_version: Literal["0.5.13"] = "0.5.13" 

3565 else: 

3566 format_version: Literal["0.5.13"] 

3567 """Version of the bioimage.io model description specification used. 

3568 When creating a new model always use the latest micro/patch version described here. 

3569 The `format_version` is important for any consumer software to understand how to parse the fields. 

3570 """ 

3571 

3572 implemented_type: ClassVar[Literal["model"]] = "model" 

3573 if TYPE_CHECKING: 

3574 type: Literal["model"] = "model" 

3575 else: 

3576 type: Literal["model"] 

3577 """Specialized resource type 'model'""" 

3578 

3579 id: ModelId | None = None 

3580 """bioimage.io-wide unique resource identifier 

3581 assigned by bioimage.io; version **un**specific.""" 

3582 

3583 authors: FAIR[list[Author]] = Field( 

3584 default_factory=cast(Callable[[], List[Author]], list) 

3585 ) 

3586 """The authors are the creators of the model RDF and the primary points of contact.""" 

3587 

3588 documentation: FAIR[FileDescr_documentation | None] = None 

3589 """Additional model documentation. 

3590 The recommended documentation source file name is `README.md`. An `.md` suffix is mandatory. 

3591 The documentation should include a '#[#] Validation' (sub)section 

3592 with details on how to quantitatively validate the model on unseen data.""" 

3593 

3594 @field_validator("documentation", mode="after") 

3595 @classmethod 

3596 def _validate_documentation(cls, value: FileDescr | None) -> FileDescr | None: 

3597 if not get_validation_context().perform_io_checks or value is None: 

3598 return value 

3599 

3600 doc_reader = get_reader(value) 

3601 doc_content = doc_reader.read().decode(encoding="utf-8") 

3602 if not re.search("#.*[vV]alidation", doc_content): 

3603 issue_warning( 

3604 "No '# Validation' (sub)section found in {value}.", 

3605 value=value, 

3606 field="documentation", 

3607 ) 

3608 

3609 return value 

3610 

3611 inputs: NotEmpty[Sequence[InputTensorDescr]] 

3612 """Describes the input tensors expected by this model.""" 

3613 

3614 @field_validator("inputs", mode="after") 

3615 @classmethod 

3616 def _validate_input_axes( 

3617 cls, inputs: Sequence[InputTensorDescr] 

3618 ) -> Sequence[InputTensorDescr]: 

3619 input_size_refs = cls._get_axes_with_independent_size(inputs) 

3620 

3621 for i, ipt in enumerate(inputs): 

3622 valid_independent_refs: dict[ 

3623 tuple[TensorId, AxisId], 

3624 tuple[TensorDescr, AnyAxis, int | ParameterizedSize], 

3625 ] = { 

3626 **{ 

3627 (ipt.id, a.id): (ipt, a, a.size) 

3628 for a in ipt.axes 

3629 if not isinstance(a, BatchAxis) 

3630 and isinstance(a.size, (int, ParameterizedSize)) 

3631 }, 

3632 **input_size_refs, 

3633 } 

3634 for a, ax in enumerate(ipt.axes): 

3635 cls._validate_axis( 

3636 "inputs", 

3637 i=i, 

3638 tensor_id=ipt.id, 

3639 a=a, 

3640 axis=ax, 

3641 valid_independent_refs=valid_independent_refs, 

3642 ) 

3643 return inputs 

3644 

3645 @staticmethod 

3646 def _validate_axis( 

3647 field_name: str, 

3648 i: int, 

3649 tensor_id: TensorId, 

3650 a: int, 

3651 axis: AnyAxis, 

3652 valid_independent_refs: dict[ 

3653 tuple[TensorId, AxisId], 

3654 tuple[TensorDescr, AnyAxis, int | ParameterizedSize], 

3655 ], 

3656 ): 

3657 if isinstance(axis, BatchAxis) or isinstance( 

3658 axis.size, (int, ParameterizedSize, DataDependentSize) 

3659 ): 

3660 return 

3661 elif not isinstance(axis.size, SizeReference): 

3662 assert_never(axis.size) 

3663 

3664 # validate axis.size SizeReference 

3665 ref = (axis.size.tensor_id, axis.size.axis_id) 

3666 if ref not in valid_independent_refs: 

3667 raise ValueError( 

3668 "Invalid tensor axis reference at" 

3669 + f" {field_name}[{i}].axes[{a}].size: {axis.size}." 

3670 ) 

3671 if ref == (tensor_id, axis.id): 

3672 raise ValueError( 

3673 "Self-referencing not allowed for" 

3674 + f" {field_name}[{i}].axes[{a}].size: {axis.size}" 

3675 ) 

3676 if axis.type == "channel": 

3677 if valid_independent_refs[ref][1].type != "channel": 

3678 raise ValueError( 

3679 "A channel axis' size may only reference another fixed size" 

3680 + " channel axis." 

3681 ) 

3682 if isinstance(axis.channel_names, str) and "{i}" in axis.channel_names: 

3683 ref_size = valid_independent_refs[ref][2] 

3684 assert isinstance(ref_size, int), ( 

3685 "channel axis ref (another channel axis) has to specify fixed" 

3686 + " size" 

3687 ) 

3688 generated_channel_names = [ 

3689 axis.channel_names.format(i=i) for i in range(1, ref_size + 1) 

3690 ] 

3691 axis.channel_names = generated_channel_names 

3692 

3693 if (ax_unit := getattr(axis, "unit", None)) != ( 

3694 ref_unit := getattr(valid_independent_refs[ref][1], "unit", None) 

3695 ): 

3696 raise ValueError( 

3697 "The units of an axis and its reference axis need to match, but" 

3698 + f" '{ax_unit}' != '{ref_unit}'." 

3699 ) 

3700 ref_axis = valid_independent_refs[ref][1] 

3701 if isinstance(ref_axis, BatchAxis): 

3702 raise ValueError( 

3703 f"Invalid reference axis '{ref_axis.id}' for {tensor_id}.{axis.id}" 

3704 + " (a batch axis is not allowed as reference)." 

3705 ) 

3706 

3707 if isinstance(axis, WithHalo): 

3708 min_size = axis.size.get_size(axis, ref_axis, n=0) 

3709 if (min_size - 2 * axis.halo) < 1: 

3710 raise ValueError( 

3711 f"axis {axis.id} with minimum size {min_size} is too small for halo" 

3712 + f" {axis.halo}." 

3713 ) 

3714 

3715 ref_halo = axis.halo * axis.scale / ref_axis.scale 

3716 if ref_halo != int(ref_halo): 

3717 raise ValueError( 

3718 f"Inferred halo for {'.'.join(ref)} is not an integer ({ref_halo} =" 

3719 + f" {tensor_id}.{axis.id}.halo {axis.halo}" 

3720 + f" * {tensor_id}.{axis.id}.scale {axis.scale}" 

3721 + f" / {'.'.join(ref)}.scale {ref_axis.scale})." 

3722 ) 

3723 

3724 def validate_input_tensors( 

3725 self, 

3726 sources: Sequence[NDArray[Any]] | Mapping[TensorId, NDArray[Any] | None], 

3727 *, 

3728 pad_inputs: bool | Literal["allow"] = True, 

3729 crop_outputs: bool | Literal["allow"] = True, 

3730 ) -> Mapping[TensorId, NDArray[Any] | None]: 

3731 """Check if the given input tensors match the model's input tensor descriptions. 

3732 This includes checks of tensor shapes and dtypes, but not of the actual values. 

3733 """ 

3734 if not isinstance(sources, collections.abc.Mapping): 

3735 sources = {descr.id: tensor for descr, tensor in zip(self.inputs, sources)} 

3736 

3737 tensors = { 

3738 **{descr.id: (descr, sources.get(descr.id)) for descr in self.inputs}, 

3739 **{ # outputs are required for halo 

3740 descr.id: (descr, None) for descr in self.outputs 

3741 }, 

3742 } 

3743 validate_tensors(tensors, pad_inputs=pad_inputs, crop_outputs=crop_outputs) 

3744 

3745 return sources 

3746 

3747 @model_validator(mode="after") 

3748 def _validate_test_tensors(self) -> Self: 

3749 if not get_validation_context().perform_io_checks: 

3750 return self 

3751 

3752 test_inputs = { 

3753 descr.id: ( 

3754 descr, 

3755 None if descr.test_tensor is None else load_array(descr.test_tensor), 

3756 ) 

3757 for descr in self.inputs 

3758 } 

3759 test_outputs = { 

3760 descr.id: ( 

3761 descr, 

3762 None if descr.test_tensor is None else load_array(descr.test_tensor), 

3763 ) 

3764 for descr in self.outputs 

3765 } 

3766 

3767 validate_tensors( 

3768 {**test_inputs, **test_outputs}, 

3769 tensor_origin="test_tensor", 

3770 pad_inputs="allow", 

3771 crop_outputs="allow", 

3772 ) 

3773 

3774 for rep_tol in self.config.bioimageio.reproducibility_tolerance: 

3775 if not rep_tol.absolute_tolerance: 

3776 continue 

3777 

3778 if rep_tol.output_ids: 

3779 out_arrays = { 

3780 k: v[1] for k, v in test_outputs.items() if k in rep_tol.output_ids 

3781 } 

3782 else: 

3783 out_arrays = {k: v[1] for k, v in test_outputs.items()} 

3784 

3785 for out_id, array in out_arrays.items(): 

3786 if array is None: 

3787 continue 

3788 

3789 if rep_tol.absolute_tolerance > (max_test_value := array.max()) * 0.01: 

3790 raise ValueError( 

3791 "config.bioimageio.reproducibility_tolerance.absolute_tolerance=" 

3792 + f"{rep_tol.absolute_tolerance} > 0.01*{max_test_value}" 

3793 + f" (1% of the maximum value of the test tensor '{out_id}')" 

3794 ) 

3795 

3796 return self 

3797 

3798 @model_validator(mode="after") 

3799 def _validate_tensor_references_in_proc_kwargs(self, info: ValidationInfo) -> Self: 

3800 ipt_refs = {t.id for t in self.inputs} 

3801 missing_refs = [ 

3802 k["reference_tensor"] 

3803 for k in [p.kwargs for ipt in self.inputs for p in ipt.preprocessing] 

3804 + [p.kwargs for out in self.outputs for p in out.postprocessing] 

3805 if "reference_tensor" in k 

3806 and k["reference_tensor"] is not None 

3807 and k["reference_tensor"] not in ipt_refs 

3808 ] 

3809 

3810 if missing_refs: 

3811 raise ValueError( 

3812 f"`reference_tensor`s {missing_refs} not found. Valid input tensor" 

3813 + f" references are: {ipt_refs}." 

3814 ) 

3815 

3816 return self 

3817 

3818 name: Annotated[ 

3819 str, 

3820 RestrictCharacters(string.ascii_letters + string.digits + "_+- ()"), 

3821 MinLen(5), 

3822 MaxLen(128), 

3823 warn(MaxLen(64), "Name longer than 64 characters.", INFO), 

3824 ] 

3825 """A human-readable name of this model. 

3826 It should be no longer than 64 characters 

3827 and may only contain letter, number, underscore, minus, parentheses and spaces. 

3828 We recommend to chose a name that refers to the model's task and image modality. 

3829 """ 

3830 

3831 outputs: NotEmpty[Sequence[OutputTensorDescr]] 

3832 """Describes the output tensors.""" 

3833 

3834 @field_validator("outputs", mode="after") 

3835 @classmethod 

3836 def _validate_tensor_ids( 

3837 cls, outputs: Sequence[OutputTensorDescr], info: ValidationInfo 

3838 ) -> Sequence[OutputTensorDescr]: 

3839 tensor_ids = [ 

3840 t.id for t in info.data.get("inputs", []) + info.data.get("outputs", []) 

3841 ] 

3842 duplicate_tensor_ids: list[str] = [] 

3843 seen: set[str] = set() 

3844 for t in tensor_ids: 

3845 if t in seen: 

3846 duplicate_tensor_ids.append(t) 

3847 

3848 seen.add(t) 

3849 

3850 if duplicate_tensor_ids: 

3851 raise ValueError(f"Duplicate tensor ids: {duplicate_tensor_ids}") 

3852 

3853 return outputs 

3854 

3855 @staticmethod 

3856 def _get_axes_with_parameterized_size( 

3857 io: Sequence[InputTensorDescr] | Sequence[OutputTensorDescr], 

3858 ): 

3859 return { 

3860 f"{t.id}.{a.id}": (t, a, a.size) 

3861 for t in io 

3862 for a in t.axes 

3863 if not isinstance(a, BatchAxis) and isinstance(a.size, ParameterizedSize) 

3864 } 

3865 

3866 @staticmethod 

3867 def _get_axes_with_independent_size( 

3868 io: Sequence[InputTensorDescr] | Sequence[OutputTensorDescr], 

3869 ): 

3870 return { 

3871 (t.id, a.id): (t, a, a.size) 

3872 for t in io 

3873 for a in t.axes 

3874 if not isinstance(a, BatchAxis) 

3875 and isinstance(a.size, (int, ParameterizedSize)) 

3876 } 

3877 

3878 @field_validator("outputs", mode="after") 

3879 @classmethod 

3880 def _validate_output_axes( 

3881 cls, outputs: list[OutputTensorDescr], info: ValidationInfo 

3882 ) -> list[OutputTensorDescr]: 

3883 input_size_refs = cls._get_axes_with_independent_size( 

3884 info.data.get("inputs", []) 

3885 ) 

3886 output_size_refs = cls._get_axes_with_independent_size(outputs) 

3887 

3888 for i, out in enumerate(outputs): 

3889 valid_independent_refs: dict[ 

3890 tuple[TensorId, AxisId], 

3891 tuple[TensorDescr, AnyAxis, int | ParameterizedSize], 

3892 ] = { 

3893 **{ 

3894 (out.id, a.id): (out, a, a.size) 

3895 for a in out.axes 

3896 if not isinstance(a, BatchAxis) 

3897 and isinstance(a.size, (int, ParameterizedSize)) 

3898 }, 

3899 **input_size_refs, 

3900 **output_size_refs, 

3901 } 

3902 for a, ax in enumerate(out.axes): 

3903 cls._validate_axis( 

3904 "outputs", 

3905 i, 

3906 out.id, 

3907 a, 

3908 ax, 

3909 valid_independent_refs=valid_independent_refs, 

3910 ) 

3911 

3912 return outputs 

3913 

3914 packaged_by: list[Author] = Field( 

3915 default_factory=cast(Callable[[], List[Author]], list) 

3916 ) 

3917 """The persons that have packaged and uploaded this model. 

3918 Only required if those persons differ from the `authors`.""" 

3919 

3920 parent: LinkedModel | None = None 

3921 """The model from which this model is derived, e.g. by fine-tuning the weights.""" 

3922 

3923 @model_validator(mode="after") 

3924 def _validate_parent_is_not_self(self) -> Self: 

3925 if self.parent is not None and self.parent.id == self.id: 

3926 raise ValueError("A model description may not reference itself as parent.") 

3927 

3928 return self 

3929 

3930 run_mode: Annotated[ 

3931 RunMode | None, 

3932 warn(None, "Run mode '{value}' has limited support across consumer softwares."), 

3933 ] = None 

3934 """Custom run mode for this model: for more complex prediction procedures like test time 

3935 data augmentation that currently cannot be expressed in the specification. 

3936 No standard run modes are defined yet.""" 

3937 

3938 timestamp: Datetime = Field(default_factory=Datetime.now) 

3939 """Timestamp in [ISO 8601](#https://en.wikipedia.org/wiki/ISO_8601) format 

3940 with a few restrictions listed [here](https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat). 

3941 (In Python a datetime object is valid, too).""" 

3942 

3943 training_data: Annotated[ 

3944 None | LinkedDataset | DatasetDescr | DatasetDescr02, 

3945 Field(union_mode="left_to_right"), 

3946 ] = None 

3947 """The dataset used to train this model""" 

3948 

3949 weights: Annotated[WeightsDescr, WrapSerializer(package_weights)] 

3950 """The weights for this model. 

3951 Weights can be given for different formats, but should otherwise be equivalent. 

3952 The available weight formats determine which consumers can use this model.""" 

3953 

3954 config: Config = Field(default_factory=Config.model_construct) 

3955 

3956 @model_validator(mode="after") 

3957 def _add_default_cover(self) -> Self: 

3958 if not get_validation_context().perform_io_checks or self.covers: 

3959 return self 

3960 

3961 try: 

3962 generated_covers = generate_covers( 

3963 [ 

3964 (t, load_array(t.test_tensor)) 

3965 for t in self.inputs 

3966 if t.test_tensor is not None 

3967 ], 

3968 [ 

3969 (t, load_array(t.test_tensor)) 

3970 for t in self.outputs 

3971 if t.test_tensor is not None 

3972 ], 

3973 ) 

3974 except Exception as e: 

3975 issue_warning( 

3976 "Failed to generate cover image(s): {e}", 

3977 value=self.covers, 

3978 msg_context={"e": e}, 

3979 field="covers", 

3980 ) 

3981 else: 

3982 self.covers.extend(generated_covers) 

3983 

3984 return self 

3985 

3986 def get_input_test_arrays(self) -> list[NDArray[Any]]: 

3987 return self._get_test_arrays(self.inputs) 

3988 

3989 def get_output_test_arrays(self) -> list[NDArray[Any]]: 

3990 return self._get_test_arrays(self.outputs) 

3991 

3992 @staticmethod 

3993 def _get_test_arrays( 

3994 io_descr: Sequence[InputTensorDescr] | Sequence[OutputTensorDescr], 

3995 ): 

3996 ts: list[FileDescr] = [] 

3997 for d in io_descr: 

3998 if d.test_tensor is None: 

3999 raise ValueError( 

4000 f"Failed to get test arrays: description of '{d.id}' is missing a `test_tensor`." 

4001 ) 

4002 ts.append(d.test_tensor) 

4003 

4004 data = [load_array(t) for t in ts] 

4005 assert all(isinstance(d, np.ndarray) for d in data) 

4006 return data 

4007 

4008 @staticmethod 

4009 def get_batch_size(tensor_sizes: Mapping[TensorId, Mapping[AxisId, int]]) -> int: 

4010 batch_size = 1 

4011 tensor_with_batchsize: TensorId | None = None 

4012 for tid in tensor_sizes: 

4013 for aid, s in tensor_sizes[tid].items(): 

4014 if aid != BATCH_AXIS_ID or s == 1 or s == batch_size: 

4015 continue 

4016 

4017 if batch_size != 1: 

4018 assert tensor_with_batchsize is not None 

4019 raise ValueError( 

4020 f"batch size mismatch for tensors '{tensor_with_batchsize}' ({batch_size}) and '{tid}' ({s})" 

4021 ) 

4022 

4023 batch_size = s 

4024 tensor_with_batchsize = tid 

4025 

4026 return batch_size 

4027 

4028 def get_output_tensor_sizes( 

4029 self, input_sizes: Mapping[TensorId, Mapping[AxisId, int]] 

4030 ) -> dict[TensorId, dict[AxisId, int | _DataDepSize]]: 

4031 """Returns the tensor output sizes for given **input_sizes**. 

4032 Only if **input_sizes** has a valid input shape, the tensor output size is exact. 

4033 Otherwise it might be larger than the actual (valid) output""" 

4034 batch_size = self.get_batch_size(input_sizes) 

4035 ns = self.get_ns(input_sizes) 

4036 

4037 tensor_sizes = self.get_tensor_sizes(ns, batch_size=batch_size) 

4038 return tensor_sizes.outputs 

4039 

4040 def get_ns(self, input_sizes: Mapping[TensorId, Mapping[AxisId, int]]): 

4041 """get parameter `n` for each parameterized axis 

4042 such that the valid input size is >= the given input size""" 

4043 ret: dict[tuple[TensorId, AxisId], ParameterizedSize_N] = {} 

4044 axes = {t.id: {a.id: a for a in t.axes} for t in self.inputs} 

4045 for tid in input_sizes: 

4046 for aid, s in input_sizes[tid].items(): 

4047 size_descr = axes[tid][aid].size 

4048 if isinstance(size_descr, ParameterizedSize): 

4049 ret[(tid, aid)] = size_descr.get_n(s) 

4050 elif size_descr is None or isinstance(size_descr, (int, SizeReference)): 

4051 pass 

4052 else: 

4053 assert_never(size_descr) 

4054 

4055 return ret 

4056 

4057 def get_tensor_sizes( 

4058 self, 

4059 ns: Mapping[tuple[TensorId, AxisId], ParameterizedSize_N], 

4060 batch_size: int, 

4061 max_input_shape: Mapping[TensorId, Mapping[AxisId, int]] | None = None, 

4062 ) -> _TensorSizes: 

4063 max_axis_sizes: dict[tuple[TensorId, AxisId], int] = {} 

4064 for m, this_max_axis_sizes in (max_input_shape or {}).items(): 

4065 for a, s in this_max_axis_sizes.items(): 

4066 max_axis_sizes[(m, a)] = s 

4067 

4068 axis_sizes = self.get_axis_sizes( 

4069 ns, batch_size=batch_size, max_input_shape=max_axis_sizes 

4070 ) 

4071 return _TensorSizes( 

4072 { 

4073 t: { 

4074 aa: axis_sizes.inputs[(tt, aa)] 

4075 for tt, aa in axis_sizes.inputs 

4076 if tt == t 

4077 } 

4078 for t in {tt for tt, _ in axis_sizes.inputs} 

4079 }, 

4080 { 

4081 t: { 

4082 aa: axis_sizes.outputs[(tt, aa)] 

4083 for tt, aa in axis_sizes.outputs 

4084 if tt == t 

4085 } 

4086 for t in {tt for tt, _ in axis_sizes.outputs} 

4087 }, 

4088 ) 

4089 

4090 def get_axis_sizes( 

4091 self, 

4092 ns: Mapping[tuple[TensorId, AxisId], ParameterizedSize_N], 

4093 batch_size: int | None = None, 

4094 *, 

4095 max_input_shape: Mapping[tuple[TensorId, AxisId], int] | None = None, 

4096 ) -> _AxisSizes: 

4097 """Determine input and output block shape for scale factors **ns** 

4098 of parameterized input sizes. 

4099 

4100 Args: 

4101 ns: Scale factor `n` for each axis (keyed by (tensor_id, axis_id)) 

4102 that is parameterized as `size = min + n * step`. 

4103 batch_size: The desired size of the batch dimension. 

4104 If given **batch_size** overwrites any batch size present in 

4105 **max_input_shape**. Default 1. 

4106 max_input_shape: Limits the derived block shapes. 

4107 Each axis for which the input size, parameterized by `n`, is larger 

4108 than **max_input_shape** is set to the minimal value `n_min` for which 

4109 this is still true. 

4110 Use this for small input samples or large values of **ns**. 

4111 Or simply whenever you know the full input shape. 

4112 

4113 Returns: 

4114 Resolved axis sizes for model inputs and outputs. 

4115 """ 

4116 max_input_shape = max_input_shape or {} 

4117 if batch_size is None: 

4118 for (_t_id, a_id), s in max_input_shape.items(): 

4119 if a_id == BATCH_AXIS_ID: 

4120 batch_size = s 

4121 break 

4122 else: 

4123 batch_size = 1 

4124 

4125 all_axes = { 

4126 t.id: {a.id: a for a in t.axes} for t in chain(self.inputs, self.outputs) 

4127 } 

4128 

4129 inputs: dict[tuple[TensorId, AxisId], int] = {} 

4130 outputs: dict[tuple[TensorId, AxisId], int | _DataDepSize] = {} 

4131 

4132 def get_axis_size(a: InputAxis | OutputAxis): 

4133 if isinstance(a, BatchAxis): 

4134 if (t_descr.id, a.id) in ns: 

4135 logger.warning( 

4136 "Ignoring unexpected size increment factor (n) for batch axis" 

4137 + " of tensor '{}'.", 

4138 t_descr.id, 

4139 ) 

4140 return batch_size 

4141 elif isinstance(a.size, int): 

4142 if (t_descr.id, a.id) in ns: 

4143 logger.warning( 

4144 "Ignoring unexpected size increment factor (n) for fixed size" 

4145 + " axis '{}' of tensor '{}'.", 

4146 a.id, 

4147 t_descr.id, 

4148 ) 

4149 return a.size 

4150 elif isinstance(a.size, ParameterizedSize): 

4151 if (t_descr.id, a.id) not in ns: 

4152 raise ValueError( 

4153 "Size increment factor (n) missing for parametrized axis" 

4154 + f" '{a.id}' of tensor '{t_descr.id}'." 

4155 ) 

4156 n = ns[(t_descr.id, a.id)] 

4157 s_max = max_input_shape.get((t_descr.id, a.id)) 

4158 if s_max is not None: 

4159 n = min(n, a.size.get_n(s_max)) 

4160 

4161 return a.size.get_size(n) 

4162 

4163 elif isinstance(a.size, SizeReference): 

4164 if (t_descr.id, a.id) in ns: 

4165 logger.warning( 

4166 "Ignoring unexpected size increment factor (n) for axis '{}'" 

4167 + " of tensor '{}' with size reference.", 

4168 a.id, 

4169 t_descr.id, 

4170 ) 

4171 assert not isinstance(a, BatchAxis) 

4172 ref_axis = all_axes[a.size.tensor_id][a.size.axis_id] 

4173 assert not isinstance(ref_axis, BatchAxis) 

4174 ref_key = (a.size.tensor_id, a.size.axis_id) 

4175 ref_size = inputs.get(ref_key, outputs.get(ref_key)) 

4176 assert ref_size is not None, ref_key 

4177 assert not isinstance(ref_size, _DataDepSize), ref_key 

4178 return a.size.get_size( 

4179 axis=a, 

4180 ref_axis=ref_axis, 

4181 ref_size=ref_size, 

4182 ) 

4183 elif isinstance(a.size, DataDependentSize): 

4184 if (t_descr.id, a.id) in ns: 

4185 logger.warning( 

4186 "Ignoring unexpected increment factor (n) for data dependent" 

4187 + " size axis '{}' of tensor '{}'.", 

4188 a.id, 

4189 t_descr.id, 

4190 ) 

4191 return _DataDepSize(a.size.min, a.size.max) 

4192 else: 

4193 assert_never(a.size) 

4194 

4195 # first resolve all , but the `SizeReference` input sizes 

4196 for t_descr in self.inputs: 

4197 for a in t_descr.axes: 

4198 if not isinstance(a.size, SizeReference): 

4199 s = get_axis_size(a) 

4200 assert not isinstance(s, _DataDepSize) 

4201 inputs[t_descr.id, a.id] = s 

4202 

4203 # resolve all other input axis sizes 

4204 for t_descr in self.inputs: 

4205 for a in t_descr.axes: 

4206 if isinstance(a.size, SizeReference): 

4207 s = get_axis_size(a) 

4208 assert not isinstance(s, _DataDepSize) 

4209 inputs[t_descr.id, a.id] = s 

4210 

4211 # resolve all output axis sizes 

4212 for t_descr in self.outputs: 

4213 for a in t_descr.axes: 

4214 assert not isinstance(a.size, ParameterizedSize) 

4215 s = get_axis_size(a) 

4216 outputs[t_descr.id, a.id] = s 

4217 

4218 return _AxisSizes(inputs=inputs, outputs=outputs) 

4219 

4220 @model_validator(mode="before") 

4221 @classmethod 

4222 def _convert(cls, data: dict[str, Any]) -> dict[str, Any]: 

4223 cls.convert_from_old_format_wo_validation(data) 

4224 return data 

4225 

4226 @classmethod 

4227 def convert_from_old_format_wo_validation(cls, data: dict[str, Any]) -> None: 

4228 """Convert metadata following an older format version to this classes' format 

4229 without validating the result. 

4230 """ 

4231 if ( 

4232 data.get("type") == "model" 

4233 and isinstance(fv := data.get("format_version"), str) 

4234 and fv.count(".") == 2 

4235 ): 

4236 fv_parts = fv.split(".") 

4237 if any(not p.isdigit() for p in fv_parts): 

4238 return 

4239 

4240 fv_tuple = tuple(map(int, fv_parts)) 

4241 

4242 assert cls.implemented_format_version_tuple[0:2] == (0, 5) 

4243 if fv_tuple[:2] in ((0, 3), (0, 4)): 

4244 m04 = _ModelDescr_v0_4.load(data) 

4245 if isinstance(m04, InvalidDescr): 

4246 try: 

4247 updated = _model_conv.convert_as_dict( 

4248 m04 # pyright: ignore[reportArgumentType] 

4249 ) 

4250 except Exception as e: 

4251 logger.error( 

4252 "Failed to convert from invalid model 0.4 description." 

4253 + f"\nerror: {e}" 

4254 + "\nProceeding with model 0.5 validation without conversion." 

4255 ) 

4256 updated = None 

4257 else: 

4258 updated = _model_conv.convert_as_dict(m04) 

4259 

4260 if updated is not None: 

4261 data.clear() 

4262 data.update(updated) 

4263 

4264 elif fv_tuple[:2] == (0, 5): 

4265 # bump patch version 

4266 data["format_version"] = cls.implemented_format_version 

4267 

4268 if fv_tuple[:2] in ((0, 3), (0, 4)) or ( 

4269 fv_tuple[:2] == (0, 5) and fv_tuple[2] < 11 

4270 ): 

4271 convert_plain_covers_and_docs_and_icon(data) 

4272 

4273 

4274class _ModelConv(Converter[_ModelDescr_v0_4, ModelDescr]): 

4275 def _convert( 

4276 self, src: _ModelDescr_v0_4, tgt: type[ModelDescr | dict[str, Any]] 

4277 ) -> ModelDescr | dict[str, Any]: 

4278 name = "".join( 

4279 c if c in string.ascii_letters + string.digits + "_+- ()" else " " 

4280 for c in src.name 

4281 ) 

4282 

4283 def conv_authors(auths: Sequence[_Author_v0_4] | None): 

4284 conv = ( 

4285 _author_conv.convert if TYPE_CHECKING else _author_conv.convert_as_dict 

4286 ) 

4287 return None if auths is None else [conv(a) for a in auths] 

4288 

4289 if TYPE_CHECKING: 

4290 arch_file_conv = _arch_file_conv.convert 

4291 arch_lib_conv = _arch_lib_conv.convert 

4292 else: 

4293 arch_file_conv = _arch_file_conv.convert_as_dict 

4294 arch_lib_conv = _arch_lib_conv.convert_as_dict 

4295 

4296 input_size_refs = { 

4297 ipt.name: { 

4298 a: s 

4299 for a, s in zip( 

4300 ipt.axes, 

4301 ( 

4302 ipt.shape.min 

4303 if isinstance(ipt.shape, _ParameterizedInputShape_v0_4) 

4304 else ipt.shape 

4305 ), 

4306 ) 

4307 } 

4308 for ipt in src.inputs 

4309 if ipt.shape 

4310 } 

4311 output_size_refs = { 

4312 **{ 

4313 out.name: {a: s for a, s in zip(out.axes, out.shape)} 

4314 for out in src.outputs 

4315 if not isinstance(out.shape, _ImplicitOutputShape_v0_4) 

4316 }, 

4317 **input_size_refs, 

4318 } 

4319 

4320 return tgt( 

4321 attachments=( 

4322 [] 

4323 if src.attachments is None 

4324 else [FileDescr(source=f) for f in src.attachments.files] 

4325 ), 

4326 authors=[_author_conv.convert_as_dict(a) for a in src.authors], # pyright: ignore[reportArgumentType] 

4327 cite=[{"text": c.text, "doi": c.doi, "url": c.url} for c in src.cite], # pyright: ignore[reportArgumentType] 

4328 config=src.config, # pyright: ignore[reportArgumentType] 

4329 covers=[{"source": c} for c in src.covers], # pyright: ignore[reportArgumentType] 

4330 description=src.description, 

4331 documentation={"source": src.documentation} if src.documentation else None, # pyright: ignore[reportArgumentType] 

4332 format_version="0.5.13", 

4333 git_repo=src.git_repo, # pyright: ignore[reportArgumentType] 

4334 icon={"source": src.icon} if src.icon else None, # pyright: ignore[reportArgumentType] 

4335 id=None if src.id is None else ModelId(src.id), 

4336 id_emoji=src.id_emoji, 

4337 license=src.license, # type: ignore 

4338 links=src.links, 

4339 maintainers=[_maintainer_conv.convert_as_dict(m) for m in src.maintainers], # pyright: ignore[reportArgumentType] 

4340 name=name, 

4341 tags=src.tags, 

4342 type=src.type, 

4343 uploader=src.uploader, 

4344 version=src.version, 

4345 inputs=[ # pyright: ignore[reportArgumentType] 

4346 _input_tensor_conv.convert_as_dict(ipt, tt, st, input_size_refs) 

4347 for ipt, tt, st in zip( 

4348 src.inputs, 

4349 src.test_inputs, 

4350 src.sample_inputs or [None] * len(src.test_inputs), 

4351 ) 

4352 ], 

4353 outputs=[ # pyright: ignore[reportArgumentType] 

4354 _output_tensor_conv.convert_as_dict(out, tt, st, output_size_refs) 

4355 for out, tt, st in zip( 

4356 src.outputs, 

4357 src.test_outputs, 

4358 src.sample_outputs or [None] * len(src.test_outputs), 

4359 ) 

4360 ], 

4361 parent=( 

4362 None 

4363 if src.parent is None 

4364 else LinkedModel( 

4365 id=ModelId( 

4366 str(src.parent.id) 

4367 + ( 

4368 "" 

4369 if src.parent.version_number is None 

4370 else f"/{src.parent.version_number}" 

4371 ) 

4372 ) 

4373 ) 

4374 ), 

4375 training_data=( 

4376 None 

4377 if src.training_data is None 

4378 else ( 

4379 LinkedDataset( 

4380 id=DatasetId( 

4381 str(src.training_data.id) 

4382 + ( 

4383 "" 

4384 if src.training_data.version_number is None 

4385 else f"/{src.training_data.version_number}" 

4386 ) 

4387 ) 

4388 ) 

4389 if isinstance(src.training_data, LinkedDataset02) 

4390 else src.training_data 

4391 ) 

4392 ), 

4393 packaged_by=[_author_conv.convert_as_dict(a) for a in src.packaged_by], # pyright: ignore[reportArgumentType] 

4394 run_mode=src.run_mode, 

4395 timestamp=src.timestamp, 

4396 weights=(WeightsDescr if TYPE_CHECKING else dict)( 

4397 keras_hdf5=(w := src.weights.keras_hdf5) 

4398 and (KerasHdf5WeightsDescr if TYPE_CHECKING else dict)( 

4399 authors=conv_authors(w.authors), 

4400 source=w.source, 

4401 tensorflow_version=w.tensorflow_version or Version("1.15"), 

4402 parent=w.parent, 

4403 ), 

4404 onnx=(w := src.weights.onnx) 

4405 and (OnnxWeightsDescr if TYPE_CHECKING else dict)( 

4406 source=w.source, 

4407 authors=conv_authors(w.authors), 

4408 parent=w.parent, 

4409 opset_version=w.opset_version or 15, 

4410 ), 

4411 pytorch_state_dict=(w := src.weights.pytorch_state_dict) 

4412 and (PytorchStateDictWeightsDescr if TYPE_CHECKING else dict)( 

4413 source=w.source, 

4414 authors=conv_authors(w.authors), 

4415 parent=w.parent, 

4416 architecture=( 

4417 arch_file_conv( 

4418 w.architecture, 

4419 w.architecture_sha256, 

4420 w.kwargs, 

4421 ) 

4422 if isinstance(w.architecture, _CallableFromFile_v0_4) 

4423 else arch_lib_conv(w.architecture, w.kwargs) 

4424 ), 

4425 pytorch_version=w.pytorch_version or Version("1.10"), 

4426 dependencies=( 

4427 None 

4428 if w.dependencies is None 

4429 else (FileDescr if TYPE_CHECKING else dict)( 

4430 source=cast( 

4431 FileSource, 

4432 str(deps := w.dependencies)[ 

4433 ( 

4434 len("conda:") 

4435 if str(deps).startswith("conda:") 

4436 else 0 

4437 ) : 

4438 ], 

4439 ) 

4440 ) 

4441 ), 

4442 ), 

4443 tensorflow_js=(w := src.weights.tensorflow_js) 

4444 and (TensorflowJsWeightsDescr if TYPE_CHECKING else dict)( 

4445 source=w.source, 

4446 authors=conv_authors(w.authors), 

4447 parent=w.parent, 

4448 tensorflow_version=w.tensorflow_version or Version("1.15"), 

4449 ), 

4450 tensorflow_saved_model_bundle=( 

4451 w := src.weights.tensorflow_saved_model_bundle 

4452 ) 

4453 and (TensorflowSavedModelBundleWeightsDescr if TYPE_CHECKING else dict)( 

4454 authors=conv_authors(w.authors), 

4455 parent=w.parent, 

4456 source=w.source, 

4457 tensorflow_version=w.tensorflow_version or Version("1.15"), 

4458 dependencies=( 

4459 None 

4460 if w.dependencies is None 

4461 else (FileDescr if TYPE_CHECKING else dict)( 

4462 source=cast( 

4463 FileSource, 

4464 ( 

4465 str(w.dependencies)[len("conda:") :] 

4466 if str(w.dependencies).startswith("conda:") 

4467 else str(w.dependencies) 

4468 ), 

4469 ) 

4470 ) 

4471 ), 

4472 ), 

4473 torchscript=(w := src.weights.torchscript) 

4474 and (TorchscriptWeightsDescr if TYPE_CHECKING else dict)( 

4475 source=w.source, 

4476 authors=conv_authors(w.authors), 

4477 parent=w.parent, 

4478 pytorch_version=w.pytorch_version or Version("1.10"), 

4479 ), 

4480 ), 

4481 ) 

4482 

4483 

4484_model_conv = _ModelConv(_ModelDescr_v0_4, ModelDescr) 

4485 

4486 

4487# create better cover images for 3d data and non-image outputs 

4488def generate_covers( 

4489 inputs: Sequence[tuple[InputTensorDescr, NDArray[Any]]], 

4490 outputs: Sequence[tuple[OutputTensorDescr, NDArray[Any]]], 

4491) -> list[FileDescr]: 

4492 def squeeze( 

4493 data: NDArray[Any], axes: Sequence[AnyAxis] 

4494 ) -> tuple[NDArray[Any], list[AnyAxis]]: 

4495 """apply numpy.ndarray.squeeze while keeping track of the axis descriptions remaining""" 

4496 if data.ndim != len(axes): 

4497 raise ValueError( 

4498 f"tensor shape {data.shape} does not match described axes" 

4499 + f" {[a.id for a in axes]}" 

4500 ) 

4501 

4502 axes = [deepcopy(a) for a, s in zip(axes, data.shape) if s != 1] 

4503 return data.squeeze(), axes 

4504 

4505 def normalize( 

4506 data: NDArray[Any], axis: tuple[int, ...] | None, eps: float = 1e-7 

4507 ) -> NDArray[np.float32]: 

4508 data = data.astype("float32") 

4509 data -= data.min(axis=axis, keepdims=True) 

4510 data /= data.max(axis=axis, keepdims=True) + eps 

4511 return data 

4512 

4513 def to_2d_image(data: NDArray[Any], axes: Sequence[AnyAxis]): 

4514 original_shape = data.shape 

4515 original_axes = list(axes) 

4516 data, axes = squeeze(data, axes) 

4517 

4518 # take slice fom any batch or index axis if needed 

4519 # and convert the first channel axis and take a slice from any additional channel axes 

4520 slices: tuple[slice, ...] = () 

4521 ndim = data.ndim 

4522 ndim_need = 3 if any(isinstance(a, ChannelAxis) for a in axes) else 2 

4523 has_c_axis = False 

4524 for i, a in enumerate(axes): 

4525 s = data.shape[i] 

4526 assert s > 1 

4527 if ( 

4528 isinstance(a, (BatchAxis, IndexInputAxis, IndexOutputAxis)) 

4529 and ndim > ndim_need 

4530 ): 

4531 data = data[slices + (slice(s // 2 - 1, s // 2),)] 

4532 ndim -= 1 

4533 elif isinstance(a, ChannelAxis): 

4534 if has_c_axis: 

4535 # second channel axis 

4536 data = data[slices + (slice(0, 1),)] 

4537 ndim -= 1 

4538 else: 

4539 has_c_axis = True 

4540 if s == 2: 

4541 # visualize two channels with cyan and magenta 

4542 data = np.concatenate( 

4543 [ 

4544 data[slices + (slice(1, 2),)], 

4545 data[slices + (slice(0, 1),)], 

4546 ( 

4547 data[slices + (slice(0, 1),)] 

4548 + data[slices + (slice(1, 2),)] 

4549 ) 

4550 / 2, # TODO: take maximum instead? 

4551 ], 

4552 axis=i, 

4553 ) 

4554 elif data.shape[i] == 3: 

4555 pass # visualize 3 channels as RGB 

4556 else: 

4557 # visualize first 3 channels as RGB 

4558 data = data[slices + (slice(3),)] 

4559 

4560 assert data.shape[i] == 3 

4561 

4562 slices += (slice(None),) 

4563 

4564 data, axes = squeeze(data, axes) 

4565 assert len(axes) == ndim 

4566 # take slice from z axis if needed 

4567 slices = () 

4568 if ndim > ndim_need: 

4569 for i, a in enumerate(axes): 

4570 s = data.shape[i] 

4571 if a.id == AxisId("z"): 

4572 data = data[slices + (slice(s // 2 - 1, s // 2),)] 

4573 data, axes = squeeze(data, axes) 

4574 ndim -= 1 

4575 break 

4576 

4577 slices += (slice(None),) 

4578 

4579 # take slice from any space or time axis 

4580 slices = () 

4581 

4582 for i, a in enumerate(axes): 

4583 if ndim <= ndim_need: 

4584 break 

4585 

4586 s = data.shape[i] 

4587 assert s > 1 

4588 if isinstance( 

4589 a, (SpaceInputAxis, SpaceOutputAxis, TimeInputAxis, TimeOutputAxis) 

4590 ): 

4591 data = data[slices + (slice(s // 2 - 1, s // 2),)] 

4592 ndim -= 1 

4593 

4594 slices += (slice(None),) 

4595 

4596 del slices 

4597 data, axes = squeeze(data, axes) 

4598 assert len(axes) == ndim 

4599 

4600 if (has_c_axis and ndim != 3) or (not has_c_axis and ndim != 2): 

4601 raise ValueError( 

4602 f"Failed to construct cover image from shape {original_shape} with axes {[a.id for a in original_axes]}." 

4603 ) 

4604 

4605 if not has_c_axis: 

4606 assert ndim == 2 

4607 data = np.repeat(data[:, :, None], 3, axis=2) 

4608 axes.append(ChannelAxis(channel_names=list("RGB"))) 

4609 ndim += 1 

4610 

4611 assert ndim == 3 

4612 

4613 # transpose axis order such that longest axis comes first... 

4614 axis_order: list[int] = [int(i) for i in np.argsort(list(data.shape))] 

4615 axis_order.reverse() 

4616 # ... and channel axis is last 

4617 c = next(i for i in range(3) if isinstance(axes[i], ChannelAxis)) 

4618 axis_order.append(axis_order.pop(c)) 

4619 axes = [axes[ao] for ao in axis_order] 

4620 data = data.transpose(axis_order) 

4621 

4622 # h, w = data.shape[:2] 

4623 # if h / w in (1.0 or 2.0): 

4624 # pass 

4625 # elif h / w < 2: 

4626 # TODO: enforce 2:1 or 1:1 aspect ratio for generated cover images 

4627 

4628 norm_along = ( 

4629 tuple(i for i, a in enumerate(axes) if a.type in ("space", "time")) or None 

4630 ) 

4631 # normalize the data and map to 8 bit 

4632 data = normalize(data, norm_along) 

4633 data = (data * 255).astype("uint8") 

4634 

4635 return data 

4636 

4637 def create_diagonal_split_image(im0: NDArray[Any], im1: NDArray[Any]): 

4638 assert im0.dtype == im1.dtype == np.uint8 

4639 assert im0.shape == im1.shape 

4640 assert im0.ndim == 3 

4641 N, M, C = im0.shape 

4642 assert C == 3 

4643 out = np.ones((N, M, C), dtype="uint8") 

4644 for c in range(C): 

4645 outc = np.tril(im0[..., c]) 

4646 mask = outc == 0 

4647 outc[mask] = np.triu(im1[..., c])[mask] 

4648 out[..., c] = outc 

4649 

4650 return out 

4651 

4652 if not inputs: 

4653 raise ValueError("Missing test input tensor for cover generation.") 

4654 

4655 if not outputs: 

4656 raise ValueError("Missing test output tensor for cover generation.") 

4657 

4658 ipt_descr, ipt = inputs[0] 

4659 out_descr, out = outputs[0] 

4660 

4661 ipt_img = to_2d_image(ipt, ipt_descr.axes) 

4662 out_img = to_2d_image(out, out_descr.axes) 

4663 

4664 cover_folder = Path(mkdtemp()) 

4665 if ipt_img.shape == out_img.shape: 

4666 covers = [cover_folder / "cover.png"] 

4667 imwrite(covers[0], create_diagonal_split_image(ipt_img, out_img)) 

4668 else: 

4669 covers = [cover_folder / "input.png", cover_folder / "output.png"] 

4670 imwrite(covers[0], ipt_img) 

4671 imwrite(covers[1], out_img) 

4672 

4673 return [FileDescr(source=c) for c in covers]