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

1692 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 19:19 +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 typing_extensions import Annotated, Self, TypeAlias, assert_never, get_args 

54 

55from .._internal.common_nodes import ( 

56 InvalidDescr, 

57 KwargsNode, 

58 Node, 

59 NodeWithExplicitlySetFields, 

60) 

61from .._internal.constants import DTYPE_LIMITS 

62from .._internal.field_warning import issue_warning, warn 

63from .._internal.io import BioimageioYamlContent as BioimageioYamlContent 

64from .._internal.io import FileDescr as FileDescr 

65from .._internal.io import ( 

66 FileSource, 

67 WithSuffix, 

68 YamlValue, 

69 extract_file_name, 

70 get_reader, 

71 wo_special_file_name, 

72) 

73from .._internal.io_basics import Sha256 as Sha256 

74from .._internal.io_packaging import ( 

75 FileDescr_package, 

76 package_file_descr_serializer, 

77) 

78from .._internal.io_utils import load_array, open_bioimageio_yaml 

79from .._internal.node_converter import Converter 

80from .._internal.type_guards import is_dict, is_sequence 

81from .._internal.types import ( 

82 FAIR, 

83 AbsoluteTolerance, 

84 LowerCaseIdentifier, 

85 LowerCaseIdentifierAnno, 

86 MismatchedElementsPerMillion, 

87 RelativeTolerance, 

88 validate_identifier, 

89 validate_is_not_keyword, 

90) 

91from .._internal.types import Datetime as Datetime 

92from .._internal.types import Identifier as Identifier 

93from .._internal.types import NotEmpty as NotEmpty 

94from .._internal.types import SiUnit as SiUnit 

95from .._internal.url import HttpUrl as HttpUrl 

96from .._internal.utils import try_all_raise_last 

97from .._internal.validation_context import get_validation_context 

98from .._internal.validator_annotations import RestrictCharacters 

99from .._internal.version_type import Version as Version 

100from .._internal.warning_levels import INFO 

101from ..dataset.v0_2 import DatasetDescr as DatasetDescr02 

102from ..dataset.v0_2 import LinkedDataset as LinkedDataset02 

103from ..dataset.v0_3 import DatasetDescr as DatasetDescr 

104from ..dataset.v0_3 import DatasetId as DatasetId 

105from ..dataset.v0_3 import LinkedDataset as LinkedDataset 

106from ..dataset.v0_3 import Uploader as Uploader 

107from ..generic._v0_3_converter import convert_plain_covers_and_docs_and_icon 

108from ..generic.v0_3 import ( 

109 VALID_COVER_IMAGE_EXTENSIONS as VALID_COVER_IMAGE_EXTENSIONS, 

110) 

111from ..generic.v0_3 import Author as Author 

112from ..generic.v0_3 import BadgeDescr as BadgeDescr 

113from ..generic.v0_3 import CiteEntry as CiteEntry 

114from ..generic.v0_3 import DeprecatedLicenseId as DeprecatedLicenseId 

115from ..generic.v0_3 import Doi as Doi 

116from ..generic.v0_3 import ( 

117 FileDescr_documentation, 

118 GenericModelDescrBase, 

119 LinkedResourceBase, 

120 _author_conv, # pyright: ignore[reportPrivateUsage] 

121 _maintainer_conv, # pyright: ignore[reportPrivateUsage] 

122) 

123from ..generic.v0_3 import LicenseId as LicenseId 

124from ..generic.v0_3 import LinkedResource as LinkedResource 

125from ..generic.v0_3 import Maintainer as Maintainer 

126from ..generic.v0_3 import OrcidId as OrcidId 

127from ..generic.v0_3 import RelativeFilePath as RelativeFilePath 

128from ..generic.v0_3 import ResourceId as ResourceId 

129from .v0_4 import Author as _Author_v0_4 

130from .v0_4 import BinarizeDescr as _BinarizeDescr_v0_4 

131from .v0_4 import CallableFromDepencency as CallableFromDepencency 

132from .v0_4 import CallableFromDepencency as _CallableFromDepencency_v0_4 

133from .v0_4 import CallableFromFile as _CallableFromFile_v0_4 

134from .v0_4 import ClipDescr as _ClipDescr_v0_4 

135from .v0_4 import ImplicitOutputShape as _ImplicitOutputShape_v0_4 

136from .v0_4 import InputTensorDescr as _InputTensorDescr_v0_4 

137from .v0_4 import KnownRunMode as KnownRunMode 

138from .v0_4 import ModelDescr as _ModelDescr04 

139from .v0_4 import ModelDescr as _ModelDescr_v0_4 

140from .v0_4 import OutputTensorDescr as _OutputTensorDescr_v0_4 

141from .v0_4 import ParameterizedInputShape as _ParameterizedInputShape_v0_4 

142from .v0_4 import PostprocessingDescr as _PostprocessingDescr_v0_4 

143from .v0_4 import PreprocessingDescr as _PreprocessingDescr_v0_4 

144from .v0_4 import RunMode as RunMode 

145from .v0_4 import ScaleLinearDescr as _ScaleLinearDescr_v0_4 

146from .v0_4 import ScaleMeanVarianceDescr as _ScaleMeanVarianceDescr_v0_4 

147from .v0_4 import ScaleRangeDescr as _ScaleRangeDescr_v0_4 

148from .v0_4 import SigmoidDescr as _SigmoidDescr_v0_4 

149from .v0_4 import TensorName as _TensorName_v0_4 

150from .v0_4 import ZeroMeanUnitVarianceDescr as _ZeroMeanUnitVarianceDescr_v0_4 

151from .v0_4 import package_weights 

152 

153SpaceUnit = Literal[ 

154 "attometer", 

155 "angstrom", 

156 "centimeter", 

157 "decimeter", 

158 "exameter", 

159 "femtometer", 

160 "foot", 

161 "gigameter", 

162 "hectometer", 

163 "inch", 

164 "kilometer", 

165 "megameter", 

166 "meter", 

167 "micrometer", 

168 "mile", 

169 "millimeter", 

170 "nanometer", 

171 "parsec", 

172 "petameter", 

173 "picometer", 

174 "terameter", 

175 "yard", 

176 "yoctometer", 

177 "yottameter", 

178 "zeptometer", 

179 "zettameter", 

180] 

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

182 

183TimeUnit = Literal[ 

184 "attosecond", 

185 "centisecond", 

186 "day", 

187 "decisecond", 

188 "exasecond", 

189 "femtosecond", 

190 "gigasecond", 

191 "hectosecond", 

192 "hour", 

193 "kilosecond", 

194 "megasecond", 

195 "microsecond", 

196 "millisecond", 

197 "minute", 

198 "nanosecond", 

199 "petasecond", 

200 "picosecond", 

201 "second", 

202 "terasecond", 

203 "yoctosecond", 

204 "yottasecond", 

205 "zeptosecond", 

206 "zettasecond", 

207] 

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

209 

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

211 

212_AXIS_TYPE_MAP: Mapping[str, AxisType] = { 

213 "b": "batch", 

214 "t": "time", 

215 "i": "index", 

216 "c": "channel", 

217 "x": "space", 

218 "y": "space", 

219 "z": "space", 

220} 

221 

222_AXIS_ID_MAP = { 

223 "b": "batch", 

224 "t": "time", 

225 "i": "index", 

226 "c": "channel", 

227 "s": "channel", 

228} 

229 

230WeightsFormat = Literal[ 

231 "keras_hdf5", 

232 "keras_v3", 

233 "onnx", 

234 "pytorch_state_dict", 

235 "tensorflow_js", 

236 "tensorflow_saved_model_bundle", 

237 "torchscript", 

238] 

239 

240 

241class TensorId(LowerCaseIdentifier): 

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

243 Annotated[LowerCaseIdentifierAnno, MaxLen(32)] 

244 ] 

245 

246 

247def _normalize_axis_id(a: str): 

248 b = str(a).lower() 

249 normalized = _AXIS_ID_MAP.get(b, b) 

250 if a != normalized: 

251 logger.opt(depth=3).debug( 

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

253 ) 

254 return normalized 

255 

256 

257class AxisId(LowerCaseIdentifier): 

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

259 Annotated[ 

260 NotEmpty[str], 

261 AfterValidator(_normalize_axis_id), 

262 MaxLen(16), 

263 AfterValidator(validate_identifier), 

264 AfterValidator(validate_is_not_keyword), 

265 ] 

266 ] 

267 

268 

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

270 return str(a) == "batch" 

271 

272 

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

274 return not _is_batch(a) 

275 

276 

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

278 

279PreprocessingId = Literal[ 

280 "binarize", 

281 "clip", 

282 "ensure_dtype", 

283 "fixed_zero_mean_unit_variance", 

284 "scale_linear", 

285 "scale_range", 

286 "sigmoid", 

287 "softmax", 

288] 

289PostprocessingId = Literal[ 

290 "binarize", 

291 "clip", 

292 "custom", 

293 "ensure_dtype", 

294 "fixed_zero_mean_unit_variance", 

295 "scale_linear", 

296 "scale_mean_variance", 

297 "scale_range", 

298 "sigmoid", 

299 "softmax", 

300 "zero_mean_unit_variance", 

301] 

302 

303 

304SAME_AS_TYPE = "<same as type>" 

305 

306 

307ParameterizedSize_N: TypeAlias = int 

308""" 

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

310""" 

311 

312 

313class ParameterizedSize(Node): 

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

315 

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

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

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

319 This allows to adjust the axis size more generically. 

320 """ 

321 

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

323 """Positive integer to parameterize this axis""" 

324 

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

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

327 

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

329 if size < self.min: 

330 raise ValueError( 

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

332 ) 

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

334 raise ValueError( 

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

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

337 ) 

338 

339 return size 

340 

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

342 return self.min + self.step * n 

343 

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

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

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

347 

348 

349class DataDependentSize(Node): 

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

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

352 

353 @model_validator(mode="after") 

354 def _validate_max_gt_min(self): 

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

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

357 

358 return self 

359 

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

361 if size < self.min: 

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

363 

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

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

366 

367 return size 

368 

369 

370class SizeReference(Node): 

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

372 

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

374 

375 Note: 

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

377 2. Batch axes may not be referenced. 

378 3. Fractions are rounded down. 

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

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

381 

382 Example: 

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

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

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

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

387 

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

389 >>> h = SpaceInputAxis( 

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

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

392 ... unit="millimeter", 

393 ... scale=4, 

394 ... ) 

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

396 49 

397 

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

399 """ 

400 

401 tensor_id: TensorId 

402 """tensor id of the reference axis""" 

403 

404 axis_id: AxisId 

405 """axis id of the reference axis""" 

406 

407 offset: StrictInt = 0 

408 

409 def get_size( 

410 self, 

411 axis: ChannelAxis 

412 | IndexInputAxis 

413 | IndexOutputAxis 

414 | TimeInputAxis 

415 | SpaceInputAxis 

416 | TimeOutputAxis 

417 | TimeOutputAxisWithHalo 

418 | SpaceOutputAxis 

419 | SpaceOutputAxisWithHalo, 

420 ref_axis: ChannelAxis 

421 | IndexInputAxis 

422 | IndexOutputAxis 

423 | TimeInputAxis 

424 | SpaceInputAxis 

425 | TimeOutputAxis 

426 | TimeOutputAxisWithHalo 

427 | SpaceOutputAxis 

428 | SpaceOutputAxisWithHalo, 

429 n: ParameterizedSize_N = 0, 

430 ref_size: int | None = None, 

431 ): 

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

433 

434 Args: 

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

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

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

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

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

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

441 **ref_axis** 

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

443 """ 

444 assert axis.size == self, ( 

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

446 ) 

447 

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

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

450 ) 

451 

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

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

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

455 ) 

456 if ref_size is None: 

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

458 ref_size = ref_axis.size 

459 elif isinstance(ref_axis.size, ParameterizedSize): 

460 ref_size = ref_axis.size.get_size(n) 

461 elif isinstance(ref_axis.size, DataDependentSize): 

462 raise ValueError( 

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

464 ) 

465 elif isinstance(ref_axis.size, SizeReference): 

466 raise ValueError( 

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

468 + " `SizeReference` itself." 

469 ) 

470 else: 

471 assert_never(ref_axis.size) 

472 

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

474 

475 @staticmethod 

476 def _get_unit( 

477 axis: ChannelAxis 

478 | IndexInputAxis 

479 | IndexOutputAxis 

480 | TimeInputAxis 

481 | SpaceInputAxis 

482 | TimeOutputAxis 

483 | TimeOutputAxisWithHalo 

484 | SpaceOutputAxis 

485 | SpaceOutputAxisWithHalo, 

486 ): 

487 return axis.unit 

488 

489 

490class AxisBase(NodeWithExplicitlySetFields): 

491 id: AxisId 

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

493 

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

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

496 

497 

498class WithHalo(Node): 

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

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

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

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

503 

504 size: Annotated[ 

505 SizeReference, 

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

507 ] 

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

509 

510 

511BATCH_AXIS_ID = AxisId("batch") 

512CHANNEL_AXIS_ID = AxisId("channel") 

513DEFAULT_SPACE_AXIS_ID = AxisId("x") 

514DEFAULT_INDEX_AXIS_ID = AxisId("index") 

515DEFAULT_TIME_AXIS_ID = AxisId("time") 

516 

517 

518class BatchAxis(AxisBase): 

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

520 if TYPE_CHECKING: 

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

522 else: 

523 type: Literal["batch"] 

524 

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

526 size: Literal[1] | None = None 

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

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

529 

530 @property 

531 def scale(self): 

532 return 1.0 

533 

534 @property 

535 def concatenable(self): 

536 return True 

537 

538 @property 

539 def unit(self): 

540 return None 

541 

542 

543class ChannelAxis(AxisBase): 

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

545 if TYPE_CHECKING: 

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

547 else: 

548 type: Literal["channel"] 

549 

550 id: NonBatchAxisId = CHANNEL_AXIS_ID 

551 

552 channel_names: NotEmpty[list[str]] 

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

554 

555 @property 

556 def size(self) -> int: 

557 return len(self.channel_names) 

558 

559 @property 

560 def concatenable(self): 

561 return False 

562 

563 @property 

564 def scale(self) -> float: 

565 return 1.0 

566 

567 @property 

568 def unit(self): 

569 return None 

570 

571 

572class _WithInputAxisSize(Node): 

573 size: Annotated[ 

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

575 Field( 

576 examples=[ 

577 10, 

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

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

580 ] 

581 ), 

582 ] 

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

584 - fixed integer 

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

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

587 """ 

588 

589 

590class IndexAxisBase(AxisBase): 

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

592 if TYPE_CHECKING: 

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

594 else: 

595 type: Literal["index"] 

596 

597 id: NonBatchAxisId = DEFAULT_INDEX_AXIS_ID 

598 

599 @property 

600 def scale(self) -> float: 

601 return 1.0 

602 

603 @property 

604 def unit(self): 

605 return None 

606 

607 

608class IndexInputAxis(IndexAxisBase, _WithInputAxisSize): 

609 concatenable: bool = False 

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

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

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

613 input axis. 

614 """ 

615 

616 

617class IndexOutputAxis(IndexAxisBase): 

618 size: Annotated[ 

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

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

621 ] 

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

623 - fixed integer 

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

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

626 """ 

627 

628 

629class TimeAxisBase(AxisBase): 

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

631 if TYPE_CHECKING: 

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

633 else: 

634 type: Literal["time"] 

635 

636 id: NonBatchAxisId = DEFAULT_TIME_AXIS_ID 

637 unit: TimeUnit | None = None 

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

639 

640 

641class TimeInputAxis(TimeAxisBase, _WithInputAxisSize): 

642 concatenable: bool = False 

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

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

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

646 input axis. 

647 """ 

648 

649 

650class SpaceAxisBase(AxisBase): 

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

652 if TYPE_CHECKING: 

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

654 else: 

655 type: Literal["space"] 

656 

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

658 DEFAULT_SPACE_AXIS_ID 

659 ) 

660 unit: SpaceUnit | None = None 

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

662 

663 

664class SpaceInputAxis(SpaceAxisBase, _WithInputAxisSize): 

665 concatenable: bool = False 

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

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

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

669 input axis. 

670 """ 

671 

672 

673INPUT_AXIS_TYPES = ( 

674 BatchAxis, 

675 ChannelAxis, 

676 IndexInputAxis, 

677 TimeInputAxis, 

678 SpaceInputAxis, 

679) 

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

681 

682_InputAxisUnion = Union[ 

683 BatchAxis, ChannelAxis, IndexInputAxis, TimeInputAxis, SpaceInputAxis 

684] 

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

686 

687 

688class _WithOutputAxisSize(Node): 

689 size: Annotated[ 

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

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

692 ] 

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

694 - fixed integer 

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

696 """ 

697 

698 

699class TimeOutputAxis(TimeAxisBase, _WithOutputAxisSize): 

700 pass 

701 

702 

703class TimeOutputAxisWithHalo(TimeAxisBase, WithHalo): 

704 pass 

705 

706 

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

708 if isinstance(v, dict): 

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

710 else: 

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

712 

713 

714_TimeOutputAxisUnion = Annotated[ 

715 Union[ 

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

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

718 ], 

719 Discriminator(_get_halo_axis_discriminator_value), 

720] 

721 

722 

723class SpaceOutputAxis(SpaceAxisBase, _WithOutputAxisSize): 

724 pass 

725 

726 

727class SpaceOutputAxisWithHalo(SpaceAxisBase, WithHalo): 

728 pass 

729 

730 

731_SpaceOutputAxisUnion = Annotated[ 

732 Union[ 

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

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

735 ], 

736 Discriminator(_get_halo_axis_discriminator_value), 

737] 

738 

739 

740_OutputAxisUnion = Union[ 

741 BatchAxis, ChannelAxis, IndexOutputAxis, _TimeOutputAxisUnion, _SpaceOutputAxisUnion 

742] 

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

744 

745OUTPUT_AXIS_TYPES = ( 

746 BatchAxis, 

747 ChannelAxis, 

748 IndexOutputAxis, 

749 TimeOutputAxis, 

750 TimeOutputAxisWithHalo, 

751 SpaceOutputAxis, 

752 SpaceOutputAxisWithHalo, 

753) 

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

755 

756 

757AnyAxis = Union[InputAxis, OutputAxis] 

758 

759ANY_AXIS_TYPES = INPUT_AXIS_TYPES + OUTPUT_AXIS_TYPES 

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

761 

762TVs = Union[ 

763 NotEmpty[List[int]], 

764 NotEmpty[List[float]], 

765 NotEmpty[List[bool]], 

766 NotEmpty[List[str]], 

767] 

768 

769 

770NominalOrOrdinalDType = Literal[ 

771 "float32", 

772 "float64", 

773 "uint8", 

774 "int8", 

775 "uint16", 

776 "int16", 

777 "uint32", 

778 "int32", 

779 "uint64", 

780 "int64", 

781 "bool", 

782] 

783 

784 

785class NominalOrOrdinalDataDescr(Node): 

786 values: TVs 

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

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

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

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

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

792 """ 

793 

794 type: Annotated[ 

795 NominalOrOrdinalDType, 

796 Field( 

797 examples=[ 

798 "float32", 

799 "uint8", 

800 "uint16", 

801 "int64", 

802 "bool", 

803 ], 

804 ), 

805 ] = "uint8" 

806 

807 @model_validator(mode="after") 

808 def _validate_values_match_type( 

809 self, 

810 ) -> Self: 

811 incompatible: list[Any] = [] 

812 for v in self.values: 

813 if self.type == "bool": 

814 if not isinstance(v, bool): 

815 incompatible.append(v) 

816 elif self.type in DTYPE_LIMITS: 

817 if ( 

818 isinstance(v, (int, float)) 

819 and ( 

820 v < DTYPE_LIMITS[self.type].min 

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

822 ) 

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

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

825 ): 

826 incompatible.append(v) 

827 else: 

828 incompatible.append(v) 

829 

830 if len(incompatible) == 5: 

831 incompatible.append("...") 

832 break 

833 

834 if incompatible: 

835 raise ValueError( 

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

837 ) 

838 

839 return self 

840 

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

842 

843 @property 

844 def range(self): 

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

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

847 else: 

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

849 

850 

851IntervalOrRatioDType = Literal[ 

852 "float32", 

853 "float64", 

854 "uint8", 

855 "int8", 

856 "uint16", 

857 "int16", 

858 "uint32", 

859 "int32", 

860 "uint64", 

861 "int64", 

862] 

863 

864 

865class IntervalOrRatioDataDescr(Node): 

866 type: Annotated[ # TODO: rename to dtype 

867 IntervalOrRatioDType, 

868 Field( 

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

870 ), 

871 ] = "float32" 

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

873 None, 

874 None, 

875 ) 

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

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

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

879 scale: float = 1.0 

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

881 offset: float | None = None 

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

883 

884 @model_validator(mode="before") 

885 def _replace_inf(cls, data: Any): 

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

887 forbidden = ( 

888 "inf", 

889 "-inf", 

890 ".inf", 

891 "-.inf", 

892 float("inf"), 

893 float("-inf"), 

894 ) 

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

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

897 

898 data["range"] = tuple( 

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

900 ) 

901 

902 return data 

903 

904 

905TensorDataDescr = Union[NominalOrOrdinalDataDescr, IntervalOrRatioDataDescr] 

906 

907 

908class BinarizeKwargs(KwargsNode): 

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

910 

911 threshold: float 

912 """The fixed threshold""" 

913 

914 

915class BinarizeAlongAxisKwargs(KwargsNode): 

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

917 

918 threshold: NotEmpty[list[float]] 

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

920 

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

922 """The `threshold` axis""" 

923 

924 

925class BinarizeDescr(NodeWithExplicitlySetFields): 

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

927 

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

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

930 

931 Examples: 

932 - in YAML 

933 ```yaml 

934 postprocessing: 

935 - id: binarize 

936 kwargs: 

937 axis: 'channel' 

938 threshold: [0.25, 0.5, 0.75] 

939 ``` 

940 - in Python: 

941 

942 >>> postprocessing = [BinarizeDescr( 

943 ... kwargs=BinarizeAlongAxisKwargs( 

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

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

946 ... ) 

947 ... )] 

948 """ 

949 

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

951 if TYPE_CHECKING: 

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

953 else: 

954 id: Literal["binarize"] 

955 kwargs: BinarizeKwargs | BinarizeAlongAxisKwargs 

956 

957 

958class ClipKwargs(KwargsNode): 

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

960 

961 min: float | None = None 

962 """Minimum value for clipping. 

963 

964 Exclusive with [min_percentile][] 

965 """ 

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

967 """Minimum percentile for clipping. 

968 

969 Exclusive with [min][]. 

970 

971 In range [0, 100). 

972 """ 

973 

974 max: float | None = None 

975 """Maximum value for clipping. 

976 

977 Exclusive with `max_percentile`. 

978 """ 

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

980 """Maximum percentile for clipping. 

981 

982 Exclusive with `max`. 

983 

984 In range (1, 100]. 

985 """ 

986 

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

988 None 

989 ) 

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

991 

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

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

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

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

996 

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

998 

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

1000 

1001 @model_validator(mode="after") 

1002 def _validate(self) -> Self: 

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

1004 raise ValueError( 

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

1006 ) 

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

1008 raise ValueError( 

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

1010 ) 

1011 if ( 

1012 self.min is None 

1013 and self.min_percentile is None 

1014 and self.max is None 

1015 and self.max_percentile is None 

1016 ): 

1017 raise ValueError( 

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

1019 ) 

1020 

1021 if ( 

1022 self.axes is not None 

1023 and self.min_percentile is None 

1024 and self.max_percentile is None 

1025 ): 

1026 raise ValueError( 

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

1028 ) 

1029 

1030 return self 

1031 

1032 

1033class ClipDescr(NodeWithExplicitlySetFields): 

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

1035 

1036 See `ScaleRangeDescr` for examples. 

1037 """ 

1038 

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

1040 if TYPE_CHECKING: 

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

1042 else: 

1043 id: Literal["clip"] 

1044 

1045 kwargs: ClipKwargs 

1046 

1047 

1048class EnsureDtypeKwargs(KwargsNode): 

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

1050 

1051 dtype: Literal[ 

1052 "float32", 

1053 "float64", 

1054 "uint8", 

1055 "int8", 

1056 "uint16", 

1057 "int16", 

1058 "uint32", 

1059 "int32", 

1060 "uint64", 

1061 "int64", 

1062 "bool", 

1063 ] 

1064 

1065 

1066class EnsureDtypeDescr(NodeWithExplicitlySetFields): 

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

1068 

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

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

1071 

1072 Examples: 

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

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

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

1076 - in YAML 

1077 ```yaml 

1078 inputs: 

1079 - data: 

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

1081 preprocessing: 

1082 - id: scale_range 

1083 kwargs: 

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

1085 max_percentile: 99.8 

1086 min_percentile: 5.0 

1087 - id: clip 

1088 kwargs: 

1089 min: 0.0 

1090 max: 1.0 

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

1092 kwargs: 

1093 dtype: uint8 

1094 ``` 

1095 - in Python: 

1096 >>> preprocessing = [ 

1097 ... ScaleRangeDescr( 

1098 ... kwargs=ScaleRangeKwargs( 

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

1100 ... max_percentile= 99.8, 

1101 ... min_percentile= 5.0, 

1102 ... ) 

1103 ... ), 

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

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

1106 ... ] 

1107 """ 

1108 

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

1110 if TYPE_CHECKING: 

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

1112 else: 

1113 id: Literal["ensure_dtype"] 

1114 

1115 kwargs: EnsureDtypeKwargs 

1116 

1117 

1118class ScaleLinearKwargs(KwargsNode): 

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

1120 

1121 gain: float = 1.0 

1122 """multiplicative factor""" 

1123 

1124 offset: float = 0.0 

1125 """additive term""" 

1126 

1127 @model_validator(mode="after") 

1128 def _validate(self) -> Self: 

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

1130 raise ValueError( 

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

1132 + " != 0.0." 

1133 ) 

1134 

1135 return self 

1136 

1137 

1138class ScaleLinearAlongAxisKwargs(KwargsNode): 

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

1140 

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

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

1143 

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

1145 """multiplicative factor""" 

1146 

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

1148 """additive term""" 

1149 

1150 @model_validator(mode="after") 

1151 def _validate(self) -> Self: 

1152 if isinstance(self.gain, list): 

1153 if isinstance(self.offset, list): 

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

1155 raise ValueError( 

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

1157 ) 

1158 else: 

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

1160 elif isinstance(self.offset, list): 

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

1162 else: 

1163 raise ValueError( 

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

1165 ) 

1166 

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

1168 raise ValueError( 

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

1170 + " != 0.0." 

1171 ) 

1172 

1173 return self 

1174 

1175 

1176class ScaleLinearDescr(NodeWithExplicitlySetFields): 

1177 """Fixed linear scaling. 

1178 

1179 Examples: 

1180 1. Scale with scalar gain and offset 

1181 - in YAML 

1182 ```yaml 

1183 preprocessing: 

1184 - id: scale_linear 

1185 kwargs: 

1186 gain: 2.0 

1187 offset: 3.0 

1188 ``` 

1189 - in Python: 

1190 

1191 >>> preprocessing = [ 

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

1193 ... ] 

1194 

1195 2. Independent scaling along an axis 

1196 - in YAML 

1197 ```yaml 

1198 preprocessing: 

1199 - id: scale_linear 

1200 kwargs: 

1201 axis: 'channel' 

1202 gain: [1.0, 2.0, 3.0] 

1203 ``` 

1204 - in Python: 

1205 

1206 >>> preprocessing = [ 

1207 ... ScaleLinearDescr( 

1208 ... kwargs=ScaleLinearAlongAxisKwargs( 

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

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

1211 ... ) 

1212 ... ) 

1213 ... ] 

1214 

1215 """ 

1216 

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

1218 if TYPE_CHECKING: 

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

1220 else: 

1221 id: Literal["scale_linear"] 

1222 kwargs: ScaleLinearKwargs | ScaleLinearAlongAxisKwargs 

1223 

1224 

1225class SigmoidDescr(NodeWithExplicitlySetFields): 

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

1227 

1228 Examples: 

1229 - in YAML 

1230 ```yaml 

1231 postprocessing: 

1232 - id: sigmoid 

1233 ``` 

1234 - in Python: 

1235 

1236 >>> postprocessing = [SigmoidDescr()] 

1237 """ 

1238 

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

1240 if TYPE_CHECKING: 

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

1242 else: 

1243 id: Literal["sigmoid"] 

1244 

1245 @property 

1246 def kwargs(self) -> KwargsNode: 

1247 """empty kwargs""" 

1248 return KwargsNode() 

1249 

1250 

1251class SoftmaxKwargs(KwargsNode): 

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

1253 

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

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

1256 Note: 

1257 Defaults to 'channel' axis 

1258 (which may not exist, in which case 

1259 a different axis id has to be specified). 

1260 """ 

1261 

1262 

1263class SoftmaxDescr(NodeWithExplicitlySetFields): 

1264 """The softmax function. 

1265 

1266 Examples: 

1267 - in YAML 

1268 ```yaml 

1269 postprocessing: 

1270 - id: softmax 

1271 kwargs: 

1272 axis: channel 

1273 ``` 

1274 - in Python: 

1275 

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

1277 """ 

1278 

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

1280 if TYPE_CHECKING: 

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

1282 else: 

1283 id: Literal["softmax"] 

1284 

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

1286 

1287 

1288class _StardistPostprocessingKwargsBase(KwargsNode): 

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

1290 

1291 prob_threshold: float 

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

1293 

1294 nms_threshold: float 

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

1296 

1297 n_rays: int 

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

1299 

1300 

1301class StardistPostprocessingKwargs2D(_StardistPostprocessingKwargsBase): 

1302 grid: tuple[int, int] 

1303 """Grid size of network predictions.""" 

1304 

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

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

1307 

1308 

1309class StardistPostprocessingKwargs3D(_StardistPostprocessingKwargsBase): 

1310 grid: tuple[int, int, int] 

1311 """Grid size of network predictions.""" 

1312 

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

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

1315 

1316 anisotropy: tuple[float, float, float] 

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

1318 

1319 overlap_label: int | None = None 

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

1321 

1322 

1323class StardistPostprocessingDescr(NodeWithExplicitlySetFields): 

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

1325 

1326 as described in: 

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

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

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

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

1331 [*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). 

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

1333 

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

1335 """ 

1336 

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

1338 "stardist_postprocessing" 

1339 ) 

1340 if TYPE_CHECKING: 

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

1342 else: 

1343 id: Literal["stardist_postprocessing"] 

1344 

1345 kwargs: StardistPostprocessingKwargs2D | StardistPostprocessingKwargs3D 

1346 

1347 

1348class CellposeFlowDynamicsKwargs(KwargsNode): 

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

1350 

1351 cellprob_threshold: float 

1352 flow_threshold: float 

1353 do_3D: bool 

1354 min_size: int = 15 

1355 """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.""" 

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

1357 

1358 

1359class CellposeFlowDynamicsDescr(NodeWithExplicitlySetFields): 

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

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

1362 

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

1364 """ 

1365 

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

1367 "cellpose_flow_dynamics" 

1368 ) 

1369 if TYPE_CHECKING: 

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

1371 else: 

1372 id: Literal["cellpose_flow_dynamics"] 

1373 

1374 kwargs: CellposeFlowDynamicsKwargs 

1375 

1376 

1377class CustomProcessingDescr(NodeWithExplicitlySetFields, FileDescr): 

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

1379 

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

1381 operations (watershed, connected components, etc.) 

1382 using a simple Python callable interface. 

1383 

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

1385 Two styles are supported: 

1386 

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

1388 

1389 .. code-block:: python 

1390 

1391 # my_postprocess.py 

1392 import numpy as np 

1393 

1394 class my_postprocess: 

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

1396 self.threshold = threshold 

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

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

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

1400 

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

1402 

1403 .. code-block:: python 

1404 

1405 # my_postprocess.py 

1406 import numpy as np 

1407 

1408 def my_postprocess(threshold: float = 0.5): 

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

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

1411 return run 

1412 

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

1414 

1415 .. code-block:: yaml 

1416 

1417 postprocessing: 

1418 - id: custom 

1419 callable: my_postprocess # class or function name in source 

1420 source: my_postprocess.py # packaged alongside weights 

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

1422 kwargs: # forwarded to __init__ / factory 

1423 threshold: 0.5 

1424 

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

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

1427 review before Zoo publication. 

1428 """ 

1429 

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

1431 if TYPE_CHECKING: 

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

1433 else: 

1434 id: Literal["custom"] 

1435 

1436 callable: Annotated[ 

1437 str, 

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

1439 ] 

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

1441 

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

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

1444 a callable satisfy this protocol.""" 

1445 

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

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

1448 

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

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

1451 ) 

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

1453 

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

1455 def _serialize( 

1456 self, nxt: SerializerFunctionWrapHandler, info: SerializationInfo 

1457 ) -> dict[str, YamlValue]: 

1458 return package_file_descr_serializer(self, nxt, info) 

1459 

1460 

1461class FixedZeroMeanUnitVarianceKwargs(KwargsNode): 

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

1463 

1464 mean: float 

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

1466 

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

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

1469 

1470 

1471class FixedZeroMeanUnitVarianceAlongAxisKwargs(KwargsNode): 

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

1473 

1474 mean: NotEmpty[list[float]] 

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

1476 

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

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

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

1480 

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

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

1483 separately.""" 

1484 

1485 @model_validator(mode="after") 

1486 def _mean_and_std_match(self) -> Self: 

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

1488 raise ValueError( 

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

1490 + " must match." 

1491 ) 

1492 

1493 return self 

1494 

1495 

1496class FixedZeroMeanUnitVarianceDescr(NodeWithExplicitlySetFields): 

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

1498 

1499 Normalize with fixed, precomputed values for 

1500 `FixedZeroMeanUnitVarianceKwargs.mean` and `FixedZeroMeanUnitVarianceKwargs.std` 

1501 Use `FixedZeroMeanUnitVarianceAlongAxisKwargs` for independent scaling along given 

1502 axes. 

1503 

1504 Examples: 

1505 1. scalar value for whole tensor 

1506 - in YAML 

1507 ```yaml 

1508 preprocessing: 

1509 - id: fixed_zero_mean_unit_variance 

1510 kwargs: 

1511 mean: 103.5 

1512 std: 13.7 

1513 ``` 

1514 - in Python 

1515 >>> preprocessing = [FixedZeroMeanUnitVarianceDescr( 

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

1517 ... )] 

1518 

1519 2. independently along an axis 

1520 - in YAML 

1521 ```yaml 

1522 preprocessing: 

1523 - id: fixed_zero_mean_unit_variance 

1524 kwargs: 

1525 axis: channel 

1526 mean: [101.5, 102.5, 103.5] 

1527 std: [11.7, 12.7, 13.7] 

1528 ``` 

1529 - in Python 

1530 >>> preprocessing = [FixedZeroMeanUnitVarianceDescr( 

1531 ... kwargs=FixedZeroMeanUnitVarianceAlongAxisKwargs( 

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

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

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

1535 ... ) 

1536 ... )] 

1537 """ 

1538 

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

1540 "fixed_zero_mean_unit_variance" 

1541 ) 

1542 if TYPE_CHECKING: 

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

1544 else: 

1545 id: Literal["fixed_zero_mean_unit_variance"] 

1546 

1547 kwargs: FixedZeroMeanUnitVarianceKwargs | FixedZeroMeanUnitVarianceAlongAxisKwargs 

1548 

1549 

1550class ZeroMeanUnitVarianceKwargs(KwargsNode): 

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

1552 

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

1554 None 

1555 ) 

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

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

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

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

1560 Default: Scale all axes jointly.""" 

1561 

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

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

1564 

1565 

1566class ZeroMeanUnitVarianceDescr(NodeWithExplicitlySetFields): 

1567 """Subtract mean and divide by variance. 

1568 

1569 Examples: 

1570 Subtract tensor mean and variance 

1571 - in YAML 

1572 ```yaml 

1573 preprocessing: 

1574 - id: zero_mean_unit_variance 

1575 ``` 

1576 - in Python 

1577 >>> preprocessing = [ZeroMeanUnitVarianceDescr()] 

1578 """ 

1579 

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

1581 "zero_mean_unit_variance" 

1582 ) 

1583 if TYPE_CHECKING: 

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

1585 else: 

1586 id: Literal["zero_mean_unit_variance"] 

1587 

1588 kwargs: ZeroMeanUnitVarianceKwargs = Field( 

1589 default_factory=ZeroMeanUnitVarianceKwargs.model_construct 

1590 ) 

1591 

1592 

1593class ScaleRangeKwargs(KwargsNode): 

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

1595 

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

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

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

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

1600 normalized values to a range. 

1601 """ 

1602 

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

1604 None 

1605 ) 

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

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

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

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

1610 Default: Scale all axes jointly.""" 

1611 

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

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

1614 

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

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

1617 Has to be bigger than `min_percentile`. 

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

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

1620 

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

1622 """Epsilon for numeric stability. 

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

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

1625 

1626 reference_tensor: TensorId | None = None 

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

1628 Default: The tensor itself. 

1629 """ 

1630 

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

1632 @classmethod 

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

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

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

1636 

1637 return value 

1638 

1639 

1640class ScaleRangeDescr(NodeWithExplicitlySetFields): 

1641 """Scale with percentiles. 

1642 

1643 Examples: 

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

1645 - in YAML 

1646 ```yaml 

1647 preprocessing: 

1648 - id: scale_range 

1649 kwargs: 

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

1651 max_percentile: 99.8 

1652 min_percentile: 5.0 

1653 ``` 

1654 - in Python 

1655 

1656 >>> preprocessing = [ 

1657 ... ScaleRangeDescr( 

1658 ... kwargs=ScaleRangeKwargs( 

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

1660 ... max_percentile= 99.8, 

1661 ... min_percentile= 5.0, 

1662 ... ) 

1663 ... ) 

1664 ... ] 

1665 

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

1667 - in YAML 

1668 ```yaml 

1669 preprocessing: 

1670 - id: scale_range 

1671 kwargs: 

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

1673 max_percentile: 99.8 

1674 min_percentile: 5.0 

1675 - id: clip 

1676 kwargs: 

1677 min: 0.0 

1678 max: 1.0 

1679 ``` 

1680 - in Python 

1681 

1682 >>> preprocessing = [ 

1683 ... ScaleRangeDescr( 

1684 ... kwargs=ScaleRangeKwargs( 

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

1686 ... max_percentile= 99.8, 

1687 ... min_percentile= 5.0, 

1688 ... ) 

1689 ... ), 

1690 ... ClipDescr( 

1691 ... kwargs=ClipKwargs( 

1692 ... min=0.0, 

1693 ... max=1.0, 

1694 ... ) 

1695 ... ), 

1696 ... ] 

1697 

1698 """ 

1699 

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

1701 if TYPE_CHECKING: 

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

1703 else: 

1704 id: Literal["scale_range"] 

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

1706 

1707 

1708class ScaleMeanVarianceKwargs(KwargsNode): 

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

1710 

1711 reference_tensor: TensorId 

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

1713 

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

1715 None 

1716 ) 

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

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

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

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

1721 Default: Scale all axes jointly.""" 

1722 

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

1724 """Epsilon for numeric stability: 

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

1726 

1727 

1728class ScaleMeanVarianceDescr(NodeWithExplicitlySetFields): 

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

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

1731 """ 

1732 

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

1734 if TYPE_CHECKING: 

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

1736 else: 

1737 id: Literal["scale_mean_variance"] 

1738 kwargs: ScaleMeanVarianceKwargs 

1739 

1740 

1741PreprocessingDescr = Annotated[ 

1742 Union[ 

1743 BinarizeDescr, 

1744 ClipDescr, 

1745 EnsureDtypeDescr, 

1746 FixedZeroMeanUnitVarianceDescr, 

1747 ScaleLinearDescr, 

1748 ScaleRangeDescr, 

1749 SigmoidDescr, 

1750 SoftmaxDescr, 

1751 ZeroMeanUnitVarianceDescr, 

1752 ], 

1753 Discriminator("id"), 

1754] 

1755PostprocessingDescr = Annotated[ 

1756 Union[ 

1757 BinarizeDescr, 

1758 CellposeFlowDynamicsDescr, 

1759 ClipDescr, 

1760 CustomProcessingDescr, 

1761 EnsureDtypeDescr, 

1762 FixedZeroMeanUnitVarianceDescr, 

1763 ScaleLinearDescr, 

1764 ScaleMeanVarianceDescr, 

1765 ScaleRangeDescr, 

1766 SigmoidDescr, 

1767 SoftmaxDescr, 

1768 StardistPostprocessingDescr, 

1769 ZeroMeanUnitVarianceDescr, 

1770 ], 

1771 Discriminator("id"), 

1772] 

1773 

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

1775 

1776 

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

1778 id: TensorId 

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

1780 

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

1782 """free text description""" 

1783 

1784 axes: NotEmpty[Sequence[IO_AxisT]] 

1785 """tensor axes""" 

1786 

1787 @property 

1788 def shape(self): 

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

1790 

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

1792 @classmethod 

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

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

1795 if len(batch_axes) > 1: 

1796 raise ValueError( 

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

1798 ) 

1799 

1800 seen_ids: set[AxisId] = set() 

1801 duplicate_axes_ids: set[AxisId] = set() 

1802 for a in axes: 

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

1804 

1805 if duplicate_axes_ids: 

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

1807 

1808 return axes 

1809 

1810 test_tensor: FAIR[FileDescr_package | None] = None 

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

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

1813 Each test tensor has be a an ndarray in the 

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

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

1816 

1817 sample_tensor: FAIR[FileDescr_package | None] = None 

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

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

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

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

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

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

1824 """ 

1825 

1826 @model_validator(mode="after") 

1827 def _validate_sample_tensor(self) -> Self: 

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

1829 return self 

1830 

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

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

1833 reader.read(), 

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

1835 ) 

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

1837 n_dims_min = n_dims_max = len(self.axes) 

1838 

1839 for a in self.axes: 

1840 if isinstance(a, BatchAxis): 

1841 n_dims_min -= 1 

1842 elif isinstance(a.size, int): 

1843 if a.size == 1: 

1844 n_dims_min -= 1 

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

1846 if a.size.min == 1: 

1847 n_dims_min -= 1 

1848 elif isinstance(a.size, SizeReference): 

1849 if a.size.offset < 2: 

1850 # size reference may result in singleton axis 

1851 n_dims_min -= 1 

1852 else: 

1853 assert_never(a.size) 

1854 

1855 n_dims_min = max(0, n_dims_min) 

1856 if n_dims < n_dims_min or n_dims > n_dims_max: 

1857 raise ValueError( 

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

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

1860 ) 

1861 

1862 return self 

1863 

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

1865 IntervalOrRatioDataDescr() 

1866 ) 

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

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

1869 

1870 @property 

1871 def dtype( 

1872 self, 

1873 ) -> Literal[ 

1874 "float32", 

1875 "float64", 

1876 "uint8", 

1877 "int8", 

1878 "uint16", 

1879 "int16", 

1880 "uint32", 

1881 "int32", 

1882 "uint64", 

1883 "int64", 

1884 "bool", 

1885 ]: 

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

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

1888 return self.data[0].type 

1889 else: 

1890 return self.data.type 

1891 

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

1893 @classmethod 

1894 def _check_data_type_across_channels( 

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

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

1897 if not isinstance(value, list): 

1898 return value 

1899 

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

1901 if len(dtypes) > 1: 

1902 raise ValueError( 

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

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

1905 ) 

1906 

1907 return value 

1908 

1909 @model_validator(mode="after") 

1910 def _check_data_matches_channelaxis(self) -> Self: 

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

1912 return self 

1913 

1914 for a in self.axes: 

1915 if isinstance(a, ChannelAxis): 

1916 size = a.size 

1917 assert isinstance(size, int) 

1918 break 

1919 else: 

1920 return self 

1921 

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

1923 raise ValueError( 

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

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

1926 ) 

1927 

1928 return self 

1929 

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

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

1932 raise ValueError( 

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

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

1935 ) 

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

1937 

1938 

1939class ConstantPadding(Node): 

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

1941 value: int | float = 0 

1942 

1943 

1944class EdgePadding(Node): 

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

1946 

1947 

1948class ReflectPadding(Node): 

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

1950 

1951 

1952class SymmetricPadding(Node): 

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

1954 

1955 

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

1957 

1958 

1959class ModelId(ResourceId): 

1960 pass 

1961 

1962 

1963class InputTensorDescr(TensorDescrBase[InputAxis]): 

1964 id: TensorId = TensorId("input") 

1965 """Input tensor id. 

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

1967 

1968 output_of: ModelId | None = None 

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

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

1971 """ 

1972 

1973 @model_validator(mode="after") 

1974 def _validate_output_of(self) -> Self: 

1975 if self.output_of is None: 

1976 return self 

1977 

1978 try: 

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

1980 opened_ref_model = open_bioimageio_yaml(self.output_of) 

1981 format_version = opened_ref_model.content["format_version"] 

1982 assert isinstance(format_version, str) 

1983 if format_version.startswith("0.4"): 

1984 ref_model = _ModelDescr04.model_validate(opened_ref_model.content) 

1985 else: 

1986 ref_model = ModelDescr.model_validate(opened_ref_model.content) 

1987 except Exception as e: 

1988 raise ValueError( 

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

1990 ) 

1991 

1992 try: 

1993 ref_model_outputs = { 

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

1995 for t in ref_model.outputs 

1996 } 

1997 except Exception as e: 

1998 raise ValueError( 

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

2000 ) 

2001 

2002 if self.id not in ref_model_outputs: 

2003 raise ValueError( 

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

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

2006 ) 

2007 return self 

2008 

2009 optional: bool = False 

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

2011 

2012 pad: Padding | None = None 

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

2014 

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

2016 

2017 Note: 

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

2019 """ 

2020 

2021 preprocessing: list[PreprocessingDescr] = Field( 

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

2023 ) 

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

2025 

2026 notes: 

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

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

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

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

2031 changing the data type. 

2032 """ 

2033 

2034 @model_validator(mode="after") 

2035 def _validate_preprocessing_kwargs(self) -> Self: 

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

2037 for p in self.preprocessing: 

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

2039 if kwargs_axes is None: 

2040 continue 

2041 

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

2043 raise ValueError( 

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

2045 ) 

2046 

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

2048 raise ValueError( 

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

2050 ) 

2051 

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

2053 dtype = self.data.type 

2054 else: 

2055 dtype = self.data[0].type 

2056 

2057 # ensure `preprocessing` begins with `EnsureDtypeDescr` 

2058 if not self.preprocessing or not isinstance( 

2059 self.preprocessing[0], EnsureDtypeDescr 

2060 ): 

2061 self.preprocessing.insert( 

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

2063 ) 

2064 

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

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

2067 self.preprocessing.append( 

2068 EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype=dtype)) 

2069 ) 

2070 

2071 return self 

2072 

2073 

2074def convert_axes( 

2075 axes: str, 

2076 *, 

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

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

2079 halo: Sequence[int] | None, 

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

2081): 

2082 ret: list[AnyAxis] = [] 

2083 for i, a in enumerate(axes): 

2084 axis_type = _AXIS_TYPE_MAP.get(a, a) 

2085 if axis_type == "batch": 

2086 ret.append(BatchAxis()) 

2087 continue 

2088 

2089 scale = 1.0 

2090 if isinstance(shape, _ParameterizedInputShape_v0_4): 

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

2092 size = shape.min[i] 

2093 else: 

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

2095 elif isinstance(shape, _ImplicitOutputShape_v0_4): 

2096 ref_t = str(shape.reference_tensor) 

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

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

2099 else: 

2100 t_id = ref_t 

2101 orig_a_id = a 

2102 

2103 a_id = _AXIS_ID_MAP.get(orig_a_id, a) 

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

2105 # old way to insert a new axis dimension 

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

2107 else: 

2108 scale = 1 / orig_scale 

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

2110 # these axes no longer have a scale 

2111 offset_from_scale = orig_scale * size_refs.get( 

2112 _TensorName_v0_4(t_id), {} 

2113 ).get(orig_a_id, 0) 

2114 else: 

2115 offset_from_scale = 0 

2116 size = SizeReference( 

2117 tensor_id=TensorId(t_id), 

2118 axis_id=AxisId(a_id), 

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

2120 ) 

2121 else: 

2122 size = shape[i] 

2123 

2124 if axis_type == "time": 

2125 if tensor_type == "input": 

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

2127 else: 

2128 assert not isinstance(size, ParameterizedSize) 

2129 if halo is None: 

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

2131 else: 

2132 assert not isinstance(size, int) 

2133 ret.append( 

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

2135 ) 

2136 

2137 elif axis_type == "index": 

2138 if tensor_type == "input": 

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

2140 else: 

2141 if isinstance(size, ParameterizedSize): 

2142 size = DataDependentSize(min=size.min) 

2143 

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

2145 elif axis_type == "channel": 

2146 assert not isinstance(size, ParameterizedSize) 

2147 if isinstance(size, SizeReference): 

2148 warnings.warn( 

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

2150 + " wrong" 

2151 ) 

2152 ret.append( 

2153 ChannelAxis( 

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

2155 ) 

2156 ) 

2157 else: 

2158 ret.append( 

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

2160 ) 

2161 elif axis_type == "space": 

2162 if tensor_type == "input": 

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

2164 else: 

2165 assert not isinstance(size, ParameterizedSize) 

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

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

2168 elif isinstance(size, int): 

2169 raise NotImplementedError( 

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

2171 ) 

2172 else: 

2173 ret.append( 

2174 SpaceOutputAxisWithHalo( 

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

2176 ) 

2177 ) 

2178 

2179 return ret 

2180 

2181 

2182def _axes_letters_to_ids( 

2183 axes: str | None, 

2184) -> list[AxisId] | None: 

2185 if axes is None: 

2186 return None 

2187 

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

2189 

2190 

2191def _get_complement_v04_axis( 

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

2193) -> AxisId | None: 

2194 if axes is None: 

2195 return None 

2196 

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

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

2199 if len(complement_axes) > 1: 

2200 raise ValueError( 

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

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

2203 ) 

2204 

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

2206 

2207 

2208def _convert_proc( 

2209 p: _PreprocessingDescr_v0_4 | _PostprocessingDescr_v0_4, 

2210 tensor_axes: Sequence[str], 

2211) -> PreprocessingDescr | PostprocessingDescr: 

2212 if isinstance(p, _BinarizeDescr_v0_4): 

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

2214 elif isinstance(p, _ClipDescr_v0_4): 

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

2216 elif isinstance(p, _SigmoidDescr_v0_4): 

2217 return SigmoidDescr() 

2218 elif isinstance(p, _ScaleLinearDescr_v0_4): 

2219 axes = _axes_letters_to_ids(p.kwargs.axes) 

2220 if p.kwargs.axes is None: 

2221 axis = None 

2222 else: 

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

2224 

2225 if axis is None: 

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

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

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

2229 else: 

2230 kwargs = ScaleLinearAlongAxisKwargs( 

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

2232 ) 

2233 return ScaleLinearDescr(kwargs=kwargs) 

2234 elif isinstance(p, _ScaleMeanVarianceDescr_v0_4): 

2235 return ScaleMeanVarianceDescr( 

2236 kwargs=ScaleMeanVarianceKwargs( 

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

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

2239 eps=p.kwargs.eps, 

2240 ) 

2241 ) 

2242 elif isinstance(p, _ZeroMeanUnitVarianceDescr_v0_4): 

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

2244 mean = p.kwargs.mean 

2245 std = p.kwargs.std 

2246 assert mean is not None 

2247 assert std is not None 

2248 

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

2250 

2251 if axis is None: 

2252 if isinstance(mean, list): 

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

2254 if isinstance(std, list): 

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

2256 return FixedZeroMeanUnitVarianceDescr( 

2257 kwargs=FixedZeroMeanUnitVarianceKwargs.model_construct( 

2258 mean=mean, 

2259 std=std, 

2260 ) 

2261 ) 

2262 else: 

2263 if not isinstance(mean, list): 

2264 mean = [float(mean)] 

2265 if not isinstance(std, list): 

2266 std = [float(std)] 

2267 

2268 return FixedZeroMeanUnitVarianceDescr( 

2269 kwargs=FixedZeroMeanUnitVarianceAlongAxisKwargs( 

2270 axis=axis, mean=mean, std=std 

2271 ) 

2272 ) 

2273 

2274 else: 

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

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

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

2278 if not axes: 

2279 axes = None 

2280 return ZeroMeanUnitVarianceDescr( 

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

2282 ) 

2283 

2284 elif isinstance(p, _ScaleRangeDescr_v0_4): 

2285 return ScaleRangeDescr( 

2286 kwargs=ScaleRangeKwargs( 

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

2288 min_percentile=p.kwargs.min_percentile, 

2289 max_percentile=p.kwargs.max_percentile, 

2290 eps=p.kwargs.eps, 

2291 ) 

2292 ) 

2293 else: 

2294 assert_never(p) 

2295 

2296 

2297class _InputTensorConv( 

2298 Converter[ 

2299 _InputTensorDescr_v0_4, 

2300 InputTensorDescr, 

2301 FileSource, 

2302 Optional[FileSource], 

2303 Mapping[_TensorName_v0_4, Mapping[str, int]], 

2304 ] 

2305): 

2306 def _convert( 

2307 self, 

2308 src: _InputTensorDescr_v0_4, 

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

2310 test_tensor: FileSource, 

2311 sample_tensor: FileSource | None, 

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

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

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

2315 src.axes, 

2316 shape=src.shape, 

2317 tensor_type="input", 

2318 halo=None, 

2319 size_refs=size_refs, 

2320 ) 

2321 prep: list[PreprocessingDescr] = [] 

2322 for p in src.preprocessing: 

2323 cp = _convert_proc(p, src.axes) 

2324 assert not isinstance( 

2325 cp, 

2326 ( 

2327 CellposeFlowDynamicsDescr, 

2328 CustomProcessingDescr, 

2329 ScaleMeanVarianceDescr, 

2330 StardistPostprocessingDescr, 

2331 ), 

2332 ) 

2333 prep.append(cp) 

2334 

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

2336 

2337 return tgt( 

2338 axes=axes, 

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

2340 test_tensor=FileDescr(source=test_tensor), 

2341 sample_tensor=( 

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

2343 ), 

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

2345 preprocessing=prep, 

2346 ) 

2347 

2348 

2349_input_tensor_conv = _InputTensorConv(_InputTensorDescr_v0_4, InputTensorDescr) 

2350 

2351 

2352class OutputTensorDescr(TensorDescrBase[OutputAxis]): 

2353 id: TensorId = TensorId("output") 

2354 """Output tensor id. 

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

2356 

2357 postprocessing: list[PostprocessingDescr] = Field( 

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

2359 ) 

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

2361 

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

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

2364 """ 

2365 

2366 @model_validator(mode="after") 

2367 def _validate_postprocessing_kwargs(self) -> Self: 

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

2369 for p in self.postprocessing: 

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

2371 if kwargs_axes is None: 

2372 continue 

2373 

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

2375 raise ValueError( 

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

2377 ) 

2378 

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

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

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

2382 

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

2384 dtype = self.data.type 

2385 else: 

2386 dtype = self.data[0].type 

2387 

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

2389 if not self.postprocessing or not isinstance( 

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

2391 ): 

2392 self.postprocessing.append( 

2393 EnsureDtypeDescr(kwargs=EnsureDtypeKwargs(dtype=dtype)) 

2394 ) 

2395 return self 

2396 

2397 

2398class _OutputTensorConv( 

2399 Converter[ 

2400 _OutputTensorDescr_v0_4, 

2401 OutputTensorDescr, 

2402 FileSource, 

2403 Optional[FileSource], 

2404 Mapping[_TensorName_v0_4, Mapping[str, int]], 

2405 ] 

2406): 

2407 def _convert( 

2408 self, 

2409 src: _OutputTensorDescr_v0_4, 

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

2411 test_tensor: FileSource, 

2412 sample_tensor: FileSource | None, 

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

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

2415 # TODO: split convert_axes into convert_output_axes and convert_input_axes 

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

2417 src.axes, 

2418 shape=src.shape, 

2419 tensor_type="output", 

2420 halo=src.halo, 

2421 size_refs=size_refs, 

2422 ) 

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

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

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

2426 

2427 return tgt( 

2428 axes=axes, 

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

2430 test_tensor=FileDescr(source=test_tensor), 

2431 sample_tensor=( 

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

2433 ), 

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

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

2436 ) 

2437 

2438 

2439_output_tensor_conv = _OutputTensorConv(_OutputTensorDescr_v0_4, OutputTensorDescr) 

2440 

2441 

2442TensorDescr = Union[InputTensorDescr, OutputTensorDescr] 

2443 

2444 

2445def get_halos( 

2446 tensors: Mapping[TensorId, TensorDescr], 

2447 /, 

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

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

2450 

2451 Note: 

2452 - Input halos are to be padded 

2453 - Output halos are to be cropped 

2454 """ 

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

2456 for descr in tensors.values(): 

2457 if isinstance(descr, InputTensorDescr): 

2458 continue 

2459 for axis in descr.axes: 

2460 if not isinstance(axis, WithHalo): 

2461 continue 

2462 

2463 ref_scale = next( 

2464 a 

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

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

2467 ).scale 

2468 

2469 # set output halo (to be cropped) 

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

2471 # set input halo (to be padded) 

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

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

2474 pad_width, 

2475 pad_width, 

2476 ) 

2477 

2478 return halos 

2479 

2480 

2481def validate_tensors( 

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

2483 tensor_origin: Literal[ 

2484 "source", "test_tensor" 

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

2486 *, 

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

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

2489): 

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

2491 

2492 Args: 

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

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

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

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

2497 """ 

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

2499 

2500 def e_msg_location(d: TensorDescr): 

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

2502 

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

2504 if array is None: 

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

2506 else: 

2507 try: 

2508 axis_sizes = descr.get_axis_sizes_for_array(array) 

2509 except ValueError as e: 

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

2511 

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

2513 

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

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

2516 

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

2518 if array is None: 

2519 continue 

2520 

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

2522 invalid_test_tensor_dtype = array.dtype.name not in ( 

2523 "float32", 

2524 "float64", 

2525 "uint8", 

2526 "int8", 

2527 "uint16", 

2528 "int16", 

2529 "uint32", 

2530 "int32", 

2531 "uint64", 

2532 "int64", 

2533 ) 

2534 else: 

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

2536 

2537 if invalid_test_tensor_dtype: 

2538 raise ValueError( 

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

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

2541 ) 

2542 

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

2544 raise ValueError( 

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

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

2547 ) 

2548 

2549 for a in descr.axes: 

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

2551 

2552 if actual_size is None: 

2553 continue 

2554 

2555 if a.size is None: 

2556 continue 

2557 

2558 # add padding width to actual tensor size 

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

2560 if isinstance(descr, InputTensorDescr): 

2561 # pad input halos 

2562 actual_size_with_halo = actual_size + total_axis_halo 

2563 if pad_inputs is True: 

2564 check_sizes = {actual_size_with_halo} 

2565 size_hint = " (after padding input halo)" 

2566 elif pad_inputs == "allow": 

2567 check_sizes = {actual_size, actual_size_with_halo} 

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

2569 elif pad_inputs is False: 

2570 check_sizes = {actual_size} 

2571 size_hint = "" 

2572 else: 

2573 assert_never(pad_inputs) 

2574 

2575 elif isinstance(descr, OutputTensorDescr): 

2576 # crop output halos 

2577 actual_size_with_halo = max(0, actual_size - total_axis_halo) 

2578 if crop_outputs is True: 

2579 check_sizes = {actual_size_with_halo} 

2580 size_hint = " (after cropping output halo)" 

2581 elif crop_outputs == "allow": 

2582 check_sizes = {actual_size, actual_size_with_halo} 

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

2584 elif crop_outputs is False: 

2585 check_sizes = {actual_size} 

2586 size_hint = "" 

2587 else: 

2588 assert_never(crop_outputs) 

2589 else: 

2590 assert_never(descr) 

2591 

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

2593 

2594 if isinstance(a.size, int): 

2595 if a.size not in check_sizes: 

2596 raise ValueError( 

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

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

2599 ) 

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

2601 _ = try_all_raise_last( 

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

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

2604 ) 

2605 elif isinstance(a.size, SizeReference): 

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

2607 if ref_tensor_axes is None: 

2608 raise ValueError( 

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

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

2611 ) 

2612 

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

2614 if ref_axis is None or ref_size is None: 

2615 raise ValueError( 

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

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

2618 ) 

2619 

2620 if a.unit != ref_axis.unit: 

2621 raise ValueError( 

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

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

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

2625 ) 

2626 

2627 if ( 

2628 expected_size := ( 

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

2630 ) 

2631 ) not in check_sizes: 

2632 raise ValueError( 

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

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

2635 + f" expected {expected_size}" 

2636 ) 

2637 else: 

2638 assert_never(a.size) 

2639 

2640 

2641FileDescr_dependencies = Annotated[ 

2642 FileDescr_package, 

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

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

2645] 

2646 

2647 

2648class _ArchitectureCallableDescr(Node): 

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

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

2651 

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

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

2654 ) 

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

2656 

2657 

2658class ArchitectureFromFileDescr(_ArchitectureCallableDescr, FileDescr): 

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

2660 """Architecture source file""" 

2661 

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

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

2664 return package_file_descr_serializer(self, nxt, info) 

2665 

2666 

2667class ArchitectureFromLibraryDescr(_ArchitectureCallableDescr): 

2668 import_from: str 

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

2670 

2671 

2672class _ArchFileConv( 

2673 Converter[ 

2674 _CallableFromFile_v0_4, 

2675 ArchitectureFromFileDescr, 

2676 Optional[Sha256], 

2677 Dict[str, Any], 

2678 ] 

2679): 

2680 def _convert( 

2681 self, 

2682 src: _CallableFromFile_v0_4, 

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

2684 sha256: Sha256 | None, 

2685 kwargs: dict[str, Any], 

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

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

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

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

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

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

2692 else: 

2693 source = str(src) 

2694 callable_ = str(src) 

2695 return tgt( 

2696 callable=Identifier(callable_), 

2697 source=cast(FileSource, source), 

2698 sha256=sha256, 

2699 kwargs=kwargs, 

2700 ) 

2701 

2702 

2703_arch_file_conv = _ArchFileConv(_CallableFromFile_v0_4, ArchitectureFromFileDescr) 

2704 

2705 

2706class _ArchLibConv( 

2707 Converter[ 

2708 _CallableFromDepencency_v0_4, ArchitectureFromLibraryDescr, Dict[str, Any] 

2709 ] 

2710): 

2711 def _convert( 

2712 self, 

2713 src: _CallableFromDepencency_v0_4, 

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

2715 kwargs: dict[str, Any], 

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

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

2718 import_from = ".".join(mods) 

2719 return tgt( 

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

2721 ) 

2722 

2723 

2724_arch_lib_conv = _ArchLibConv( 

2725 _CallableFromDepencency_v0_4, ArchitectureFromLibraryDescr 

2726) 

2727 

2728 

2729class WeightsEntryDescrBase(FileDescr): 

2730 type: ClassVar[WeightsFormat] 

2731 weights_format_name: ClassVar[str] # human readable 

2732 

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

2734 """Source of the weights file.""" 

2735 

2736 authors: list[Author] | None = None 

2737 """Authors 

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

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

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

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

2742 """ 

2743 

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

2745 None 

2746 ) 

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

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

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

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

2751 need to have this field.""" 

2752 

2753 comment: str = "" 

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

2755 

2756 @model_validator(mode="after") 

2757 def _validate(self) -> Self: 

2758 if self.type == self.parent: 

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

2760 

2761 return self 

2762 

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

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

2765 return package_file_descr_serializer(self, nxt, info) 

2766 

2767 

2768class KerasHdf5WeightsDescr(WeightsEntryDescrBase): 

2769 type: ClassVar[WeightsFormat] = "keras_hdf5" 

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

2771 tensorflow_version: Version 

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

2773 

2774 

2775class KerasV3WeightsDescr(WeightsEntryDescrBase): 

2776 type: ClassVar[WeightsFormat] = "keras_v3" 

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

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

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

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

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

2782 source: Annotated[ 

2783 FileSource, 

2784 AfterValidator(wo_special_file_name), 

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

2786 ] 

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

2788 

2789 

2790FileDescr_external_data = Annotated[ 

2791 FileDescr_package, 

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

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

2794] 

2795 

2796 

2797class OnnxWeightsDescr(WeightsEntryDescrBase): 

2798 type: ClassVar[WeightsFormat] = "onnx" 

2799 weights_format_name: ClassVar[str] = "ONNX" 

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

2801 """ONNX opset version""" 

2802 

2803 external_data: FileDescr_external_data | None = None 

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

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

2806 

2807 @model_validator(mode="after") 

2808 def _validate_external_data_unique_file_name(self) -> Self: 

2809 if self.external_data is not None and ( 

2810 extract_file_name(self.source) 

2811 == extract_file_name(self.external_data.source) 

2812 ): 

2813 raise ValueError( 

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

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

2816 ) 

2817 

2818 return self 

2819 

2820 

2821class PytorchStateDictWeightsDescr(WeightsEntryDescrBase): 

2822 type: ClassVar[WeightsFormat] = "pytorch_state_dict" 

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

2824 architecture: ArchitectureFromFileDescr | ArchitectureFromLibraryDescr 

2825 pytorch_version: Version 

2826 """Version of the PyTorch library used. 

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

2828 """ 

2829 dependencies: FileDescr_dependencies | None = None 

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

2831 Allows to specify custom dependencies, see conda docs: 

2832 - [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) 

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

2834 

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

2836 **pytorch_version**. 

2837 """ 

2838 strict: bool = True 

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

2840 

2841 

2842class TensorflowJsWeightsDescr(WeightsEntryDescrBase): 

2843 type: ClassVar[WeightsFormat] = "tensorflow_js" 

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

2845 tensorflow_version: Version 

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

2847 

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

2849 """The multi-file weights. 

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

2851 

2852 

2853class TensorflowSavedModelBundleWeightsDescr(WeightsEntryDescrBase): 

2854 type: ClassVar[WeightsFormat] = "tensorflow_saved_model_bundle" 

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

2856 tensorflow_version: Version 

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

2858 

2859 dependencies: FileDescr_dependencies | None = None 

2860 """Custom dependencies beyond tensorflow. 

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

2862 

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

2864 """The multi-file weights. 

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

2866 

2867 

2868class TorchscriptWeightsDescr(WeightsEntryDescrBase): 

2869 type: ClassVar[WeightsFormat] = "torchscript" 

2870 weights_format_name: ClassVar[str] = "TorchScript" 

2871 pytorch_version: Version 

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

2873 

2874 

2875SpecificWeightsDescr = Union[ 

2876 KerasHdf5WeightsDescr, 

2877 KerasV3WeightsDescr, 

2878 OnnxWeightsDescr, 

2879 PytorchStateDictWeightsDescr, 

2880 TensorflowJsWeightsDescr, 

2881 TensorflowSavedModelBundleWeightsDescr, 

2882 TorchscriptWeightsDescr, 

2883] 

2884 

2885 

2886class WeightsDescr(Node): 

2887 keras_hdf5: KerasHdf5WeightsDescr | None = None 

2888 keras_v3: KerasV3WeightsDescr | None = None 

2889 onnx: OnnxWeightsDescr | None = None 

2890 pytorch_state_dict: PytorchStateDictWeightsDescr | None = None 

2891 tensorflow_js: TensorflowJsWeightsDescr | None = None 

2892 tensorflow_saved_model_bundle: TensorflowSavedModelBundleWeightsDescr | None = None 

2893 torchscript: TorchscriptWeightsDescr | None = None 

2894 

2895 @model_validator(mode="after") 

2896 def check_entries(self) -> Self: 

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

2898 

2899 if not entries: 

2900 raise ValueError("Missing weights entry") 

2901 

2902 entries_wo_parent = { 

2903 wtype 

2904 for wtype, entry in self 

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

2906 } 

2907 if len(entries_wo_parent) != 1: 

2908 issue_warning( 

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

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

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

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

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

2914 value=len(entries_wo_parent), 

2915 field="weights", 

2916 ) 

2917 

2918 for wtype, entry in self: 

2919 if entry is None: 

2920 continue 

2921 

2922 assert hasattr(entry, "type") 

2923 assert hasattr(entry, "parent") 

2924 assert wtype == entry.type 

2925 if ( 

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

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

2928 raise ValueError( 

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

2930 + f" formats: {entries}" 

2931 ) 

2932 

2933 return self 

2934 

2935 def __getitem__( 

2936 self, 

2937 key: WeightsFormat, 

2938 ): 

2939 if key == "keras_hdf5": 

2940 ret = self.keras_hdf5 

2941 elif key == "keras_v3": 

2942 ret = self.keras_v3 

2943 elif key == "onnx": 

2944 ret = self.onnx 

2945 elif key == "pytorch_state_dict": 

2946 ret = self.pytorch_state_dict 

2947 elif key == "tensorflow_js": 

2948 ret = self.tensorflow_js 

2949 elif key == "tensorflow_saved_model_bundle": 

2950 ret = self.tensorflow_saved_model_bundle 

2951 elif key == "torchscript": 

2952 ret = self.torchscript 

2953 else: 

2954 raise KeyError(key) 

2955 

2956 if ret is None: 

2957 raise KeyError(key) 

2958 

2959 return ret 

2960 

2961 @overload 

2962 def __setitem__( 

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

2964 ) -> None: ... 

2965 @overload 

2966 def __setitem__( 

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

2968 ) -> None: ... 

2969 @overload 

2970 def __setitem__( 

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

2972 ) -> None: ... 

2973 @overload 

2974 def __setitem__( 

2975 self, 

2976 key: Literal["pytorch_state_dict"], 

2977 value: PytorchStateDictWeightsDescr | None, 

2978 ) -> None: ... 

2979 @overload 

2980 def __setitem__( 

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

2982 ) -> None: ... 

2983 @overload 

2984 def __setitem__( 

2985 self, 

2986 key: Literal["tensorflow_saved_model_bundle"], 

2987 value: TensorflowSavedModelBundleWeightsDescr | None, 

2988 ) -> None: ... 

2989 @overload 

2990 def __setitem__( 

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

2992 ) -> None: ... 

2993 

2994 def __setitem__( 

2995 self, 

2996 key: WeightsFormat, 

2997 value: SpecificWeightsDescr | None, 

2998 ): 

2999 if key == "keras_hdf5": 

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

3001 raise TypeError( 

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

3003 ) 

3004 self.keras_hdf5 = value 

3005 elif key == "keras_v3": 

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

3007 raise TypeError( 

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

3009 ) 

3010 self.keras_v3 = value 

3011 elif key == "onnx": 

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

3013 raise TypeError( 

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

3015 ) 

3016 self.onnx = value 

3017 elif key == "pytorch_state_dict": 

3018 if value is not None and not isinstance( 

3019 value, PytorchStateDictWeightsDescr 

3020 ): 

3021 raise TypeError( 

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

3023 ) 

3024 self.pytorch_state_dict = value 

3025 elif key == "tensorflow_js": 

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

3027 raise TypeError( 

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

3029 ) 

3030 self.tensorflow_js = value 

3031 elif key == "tensorflow_saved_model_bundle": 

3032 if value is not None and not isinstance( 

3033 value, TensorflowSavedModelBundleWeightsDescr 

3034 ): 

3035 raise TypeError( 

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

3037 ) 

3038 self.tensorflow_saved_model_bundle = value 

3039 elif key == "torchscript": 

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

3041 raise TypeError( 

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

3043 ) 

3044 self.torchscript = value 

3045 else: 

3046 raise KeyError(key) 

3047 

3048 @property 

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

3050 return { 

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

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

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

3054 **( 

3055 {} 

3056 if self.pytorch_state_dict is None 

3057 else {"pytorch_state_dict": self.pytorch_state_dict} 

3058 ), 

3059 **( 

3060 {} 

3061 if self.tensorflow_js is None 

3062 else {"tensorflow_js": self.tensorflow_js} 

3063 ), 

3064 **( 

3065 {} 

3066 if self.tensorflow_saved_model_bundle is None 

3067 else { 

3068 "tensorflow_saved_model_bundle": self.tensorflow_saved_model_bundle 

3069 } 

3070 ), 

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

3072 } 

3073 

3074 @property 

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

3076 return { 

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

3078 } 

3079 

3080 

3081class LinkedModel(LinkedResourceBase): 

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

3083 

3084 id: ModelId 

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

3086 

3087 

3088class _DataDepSize(NamedTuple): 

3089 min: StrictInt 

3090 max: StrictInt | None 

3091 

3092 

3093class _AxisSizes(NamedTuple): 

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

3095 

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

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

3098 

3099 

3100class _TensorSizes(NamedTuple): 

3101 """_AxisSizes as nested dicts""" 

3102 

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

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

3105 

3106 

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

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

3109 in the generated output when executing in different environments. 

3110 

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

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

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

3114 

3115 Motivation: 

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

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

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

3119 may still lead to numerical differences. 

3120 """ 

3121 

3122 relative_tolerance: RelativeTolerance = 1e-3 

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

3124 

3125 absolute_tolerance: AbsoluteTolerance = 1e-3 

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

3127 

3128 mismatched_elements_per_million: MismatchedElementsPerMillion = 100 

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

3130 

3131 output_ids: Sequence[TensorId] = () 

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

3133 

3134 weights_formats: Sequence[WeightsFormat] = () 

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

3136 

3137 

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

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

3140 

3141 known_biases: str = dedent("""\ 

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

3143 

3144 - Imaging protocol dependencies 

3145 - Use of a specific cell type 

3146 - Species-specific training data limitations 

3147 

3148 """) 

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

3150 

3151 risks: str = dedent("""\ 

3152 Common risks in bioimage analysis include: 

3153 

3154 - Erroneously assuming generalization to unseen experimental conditions 

3155 - Trusting (overconfident) model outputs without validation 

3156 - Misinterpretation of results 

3157 

3158 """) 

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

3160 

3161 limitations: str | None = None 

3162 """Technical limitations and failure modes.""" 

3163 

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

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

3166 

3167 Consider: 

3168 - How to use a validation dataset? 

3169 - How to manually validate? 

3170 - Feasibility of domain adaptation for different experimental setups? 

3171 

3172 """ 

3173 

3174 def format_md(self) -> str: 

3175 if self.limitations is None: 

3176 limitations_header = "" 

3177 else: 

3178 limitations_header = "## Limitations\n\n" 

3179 

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

3181 

3182{self.known_biases} 

3183 

3184{self.risks} 

3185 

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

3187 

3188## Recommendations 

3189 

3190{self.recommendations} 

3191 

3192""" 

3193 

3194 

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

3196 training_preprocessing: str | None = None 

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

3198 

3199 Mention: 

3200 - *Normalization methods* 

3201 - *Augmentation strategies* 

3202 - *Resizing/resampling procedures* 

3203 - *Artifact handling* 

3204 

3205 """ 

3206 

3207 training_epochs: float | None = None 

3208 """Number of training epochs.""" 

3209 

3210 training_batch_size: float | None = None 

3211 """Batch size used in training.""" 

3212 

3213 initial_learning_rate: float | None = None 

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

3215 

3216 learning_rate_schedule: str | None = None 

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

3218 

3219 loss_function: str | None = None 

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

3221 

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

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

3224 ) 

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

3226 

3227 optimizer: str | None = None 

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

3229 

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

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

3232 ) 

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

3234 

3235 regularization: str | None = None 

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

3237 

3238 training_duration: float | None = None 

3239 """Total training duration in hours.""" 

3240 

3241 

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

3243 model_id: ModelId | None = None 

3244 """Model being evaluated.""" 

3245 

3246 dataset_id: DatasetId 

3247 """Dataset used for evaluation.""" 

3248 

3249 dataset_source: HttpUrl 

3250 """Source of the dataset.""" 

3251 

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

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

3254 

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

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

3257 - `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 

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

3259 - `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. 

3260 """ 

3261 

3262 sample_count: int 

3263 """Number of evaluated samples.""" 

3264 

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

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

3267 

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

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

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

3271 """ 

3272 

3273 evaluation_factors_long: list[str] 

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

3275 

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

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

3278 

3279 metrics_long: list[str] 

3280 """Description of each metric used.""" 

3281 

3282 @model_validator(mode="after") 

3283 def _validate_list_lengths(self) -> Self: 

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

3285 raise ValueError( 

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

3287 ) 

3288 

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

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

3291 

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

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

3294 

3295 for row in self.results: 

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

3297 raise ValueError( 

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

3299 ) 

3300 

3301 return self 

3302 

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

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

3305 

3306 results_summary: str | None = None 

3307 """Interpretation of results for general audience. 

3308 

3309 Consider: 

3310 - Overall model performance 

3311 - Comparison to existing methods 

3312 - Limitations and areas for improvement 

3313 

3314""" 

3315 

3316 def format_md(self): 

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

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

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

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

3321 ] 

3322 

3323 results_table = "".join( 

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

3325 ) 

3326 factors = "".join( 

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

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

3329 ) 

3330 metrics = "".join( 

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

3332 ) 

3333 

3334 return f"""## Testing Data, Factors & Metrics 

3335 

3336Evaluation of {self.model_id or "this"} model on the {self.dataset_id} dataset (dataset role: {self.dataset_role}). 

3337 

3338### Testing Data 

3339 

3340- **Source:** [{self.dataset_id}]({self.dataset_source}) 

3341- **Size:** {self.sample_count} evaluated samples 

3342 

3343### Factors 

3344{factors} 

3345 

3346### Metrics 

3347{metrics} 

3348 

3349## Results 

3350 

3351### Quantitative Results 

3352 

3353{results_table} 

3354 

3355### Summary 

3356 

3357{self.results_summary or "missing"} 

3358 

3359""" 

3360 

3361 

3362class EnvironmentalImpact(Node, extra="allow"): 

3363 """Environmental considerations for model training and deployment. 

3364 

3365 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). 

3366 """ 

3367 

3368 hardware_type: str | None = None 

3369 """GPU/CPU specifications""" 

3370 

3371 hours_used: float | None = None 

3372 """Total compute hours""" 

3373 

3374 cloud_provider: str | None = None 

3375 """If applicable""" 

3376 

3377 compute_region: str | None = None 

3378 """Geographic location""" 

3379 

3380 co2_emitted: float | None = None 

3381 """kg CO2 equivalent 

3382 

3383 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). 

3384 """ 

3385 

3386 def format_md(self): 

3387 """Filled Markdown template section following [Hugging Face Model Card Template](https://huggingface.co/docs/hub/en/model-card-annotated).""" 

3388 if self == self.__class__(): 

3389 return "" 

3390 

3391 ret = "# Environmental Impact\n\n" 

3392 if self.hardware_type is not None: 

3393 ret += f"- **Hardware Type:** {self.hardware_type}\n" 

3394 if self.hours_used is not None: 

3395 ret += f"- **Hours used:** {self.hours_used}\n" 

3396 if self.cloud_provider is not None: 

3397 ret += f"- **Cloud Provider:** {self.cloud_provider}\n" 

3398 if self.compute_region is not None: 

3399 ret += f"- **Compute Region:** {self.compute_region}\n" 

3400 if self.co2_emitted is not None: 

3401 ret += f"- **Carbon Emitted:** {self.co2_emitted} kg CO2e\n" 

3402 

3403 return ret + "\n" 

3404 

3405 

3406class BioimageioConfig(Node, extra="allow"): 

3407 reproducibility_tolerance: Sequence[ReproducibilityTolerance] = () 

3408 """Tolerances to allow when reproducing the model's test outputs 

3409 from the model's test inputs. 

3410 Only the first entry matching tensor id and weights format is considered. 

3411 """ 

3412 

3413 funded_by: str | None = None 

3414 """Funding agency, grant number if applicable""" 

3415 

3416 architecture_type: Annotated[str, MaxLen(32)] | None = ( 

3417 None # TODO: add to differentiated tags 

3418 ) 

3419 """Model architecture type, e.g., 3D U-Net, ResNet, transformer""" 

3420 

3421 architecture_description: str | None = None 

3422 """Text description of model architecture.""" 

3423 

3424 modality: str | None = None # TODO: add to differentiated tags 

3425 """Input modality, e.g., fluorescence microscopy, electron microscopy""" 

3426 

3427 target_structure: list[str] = Field( # TODO: add to differentiated tags 

3428 default_factory=cast(Callable[[], List[str]], list) 

3429 ) 

3430 """Biological structure(s) the model is designed to analyze, e.g., nuclei, mitochondria, cells""" 

3431 

3432 task: str | None = None # TODO: add to differentiated tags 

3433 """Bioimage-specific task type, e.g., segmentation, classification, detection, denoising""" 

3434 

3435 new_version: ModelId | None = None 

3436 """A new version of this model exists with a different model id.""" 

3437 

3438 out_of_scope_use: str | None = None 

3439 """Describe how the model may be misused in bioimage analysis contexts and what users should **not** do with the model.""" 

3440 

3441 bias_risks_limitations: BiasRisksLimitations = Field( 

3442 default_factory=BiasRisksLimitations.model_construct 

3443 ) 

3444 """Description of known bias, risks, and technical limitations for in-scope model use.""" 

3445 

3446 model_parameter_count: int | None = None 

3447 """Total number of model parameters.""" 

3448 

3449 training: TrainingDetails = Field(default_factory=TrainingDetails.model_construct) 

3450 """Details on how the model was trained.""" 

3451 

3452 inference_time: str | None = None 

3453 """Average inference time per image/tile. Specify hardware and image size. Multiple examples can be given.""" 

3454 

3455 memory_requirements_inference: str | None = None 

3456 """GPU memory needed for inference. Multiple examples with different image size can be given.""" 

3457 

3458 memory_requirements_training: str | None = None 

3459 """GPU memory needed for training. Multiple examples with different image/batch sizes can be given.""" 

3460 

3461 evaluations: list[Evaluation] = Field( 

3462 default_factory=cast(Callable[[], List[Evaluation]], list) 

3463 ) 

3464 """Quantitative model evaluations. 

3465 

3466 Note: 

3467 At the moment we recommend to include only a single test dataset 

3468 (with evaluation factors that may mark subsets of the dataset) 

3469 to avoid confusion and make the presentation of results cleaner. 

3470 """ 

3471 

3472 environmental_impact: EnvironmentalImpact = Field( 

3473 default_factory=EnvironmentalImpact.model_construct 

3474 ) 

3475 """Environmental considerations for model training and deployment""" 

3476 

3477 

3478class Config(Node, extra="allow"): 

3479 bioimageio: BioimageioConfig = Field( 

3480 default_factory=BioimageioConfig.model_construct 

3481 ) 

3482 stardist: YamlValue = None 

3483 

3484 

3485class ModelDescr(GenericModelDescrBase): 

3486 """Specification of the fields used in a bioimage.io-compliant RDF to describe AI models with pretrained weights. 

3487 These fields are typically stored in a YAML file which we call a model resource description file (model RDF). 

3488 """ 

3489 

3490 implemented_format_version: ClassVar[Literal["0.5.14"]] = "0.5.14" 

3491 if TYPE_CHECKING: 

3492 format_version: Literal["0.5.14"] = "0.5.14" 

3493 else: 

3494 format_version: Literal["0.5.14"] 

3495 """Version of the bioimage.io model description specification used. 

3496 When creating a new model always use the latest micro/patch version described here. 

3497 The `format_version` is important for any consumer software to understand how to parse the fields. 

3498 """ 

3499 

3500 implemented_type: ClassVar[Literal["model"]] = "model" 

3501 if TYPE_CHECKING: 

3502 type: Literal["model"] = "model" 

3503 else: 

3504 type: Literal["model"] 

3505 """Specialized resource type 'model'""" 

3506 

3507 id: ModelId | None = None 

3508 """bioimage.io-wide unique resource identifier 

3509 assigned by bioimage.io; version **un**specific.""" 

3510 

3511 authors: FAIR[list[Author]] = Field( 

3512 default_factory=cast(Callable[[], List[Author]], list) 

3513 ) 

3514 """The authors are the creators of the model RDF and the primary points of contact.""" 

3515 

3516 documentation: FAIR[FileDescr_documentation | None] = None 

3517 """Additional model documentation. 

3518 The recommended documentation source file name is `README.md`. An `.md` suffix is mandatory. 

3519 The documentation should include a '#[#] Validation' (sub)section 

3520 with details on how to quantitatively validate the model on unseen data.""" 

3521 

3522 @field_validator("documentation", mode="after") 

3523 @classmethod 

3524 def _validate_documentation(cls, value: FileDescr | None) -> FileDescr | None: 

3525 if not get_validation_context().perform_io_checks or value is None: 

3526 return value 

3527 

3528 doc_reader = get_reader(value) 

3529 doc_content = doc_reader.read().decode(encoding="utf-8") 

3530 if not re.search("#.*[vV]alidation", doc_content): 

3531 issue_warning( 

3532 "No '# Validation' (sub)section found in {value}.", 

3533 value=value, 

3534 field="documentation", 

3535 ) 

3536 

3537 return value 

3538 

3539 inputs: NotEmpty[Sequence[InputTensorDescr]] 

3540 """Describes the input tensors expected by this model.""" 

3541 

3542 @field_validator("inputs", mode="after") 

3543 @classmethod 

3544 def _validate_input_axes( 

3545 cls, inputs: Sequence[InputTensorDescr] 

3546 ) -> Sequence[InputTensorDescr]: 

3547 input_size_refs = cls._get_axes_with_independent_size(inputs) 

3548 

3549 for i, ipt in enumerate(inputs): 

3550 valid_independent_refs: dict[ 

3551 tuple[TensorId, AxisId], 

3552 tuple[TensorDescr, AnyAxis, int | ParameterizedSize], 

3553 ] = { 

3554 **{ 

3555 (ipt.id, a.id): (ipt, a, a.size) 

3556 for a in ipt.axes 

3557 if not isinstance(a, BatchAxis) 

3558 and isinstance(a.size, (int, ParameterizedSize)) 

3559 }, 

3560 **input_size_refs, 

3561 } 

3562 for a, ax in enumerate(ipt.axes): 

3563 cls._validate_axis( 

3564 "inputs", 

3565 i=i, 

3566 tensor_id=ipt.id, 

3567 a=a, 

3568 axis=ax, 

3569 valid_independent_refs=valid_independent_refs, 

3570 ) 

3571 return inputs 

3572 

3573 @staticmethod 

3574 def _validate_axis( 

3575 field_name: str, 

3576 i: int, 

3577 tensor_id: TensorId, 

3578 a: int, 

3579 axis: AnyAxis, 

3580 valid_independent_refs: dict[ 

3581 tuple[TensorId, AxisId], 

3582 tuple[TensorDescr, AnyAxis, int | ParameterizedSize], 

3583 ], 

3584 ): 

3585 if isinstance(axis, BatchAxis) or isinstance( 

3586 axis.size, (int, ParameterizedSize, DataDependentSize) 

3587 ): 

3588 return 

3589 elif not isinstance(axis.size, SizeReference): 

3590 assert_never(axis.size) 

3591 

3592 # validate axis.size SizeReference 

3593 ref = (axis.size.tensor_id, axis.size.axis_id) 

3594 if ref not in valid_independent_refs: 

3595 raise ValueError( 

3596 "Invalid tensor axis reference at" 

3597 + f" {field_name}[{i}].axes[{a}].size: {axis.size}." 

3598 ) 

3599 if ref == (tensor_id, axis.id): 

3600 raise ValueError( 

3601 "Self-referencing not allowed for" 

3602 + f" {field_name}[{i}].axes[{a}].size: {axis.size}" 

3603 ) 

3604 if axis.type == "channel": 

3605 if valid_independent_refs[ref][1].type != "channel": 

3606 raise ValueError( 

3607 "A channel axis' size may only reference another fixed size" 

3608 + " channel axis." 

3609 ) 

3610 if isinstance(axis.channel_names, str) and "{i}" in axis.channel_names: 

3611 ref_size = valid_independent_refs[ref][2] 

3612 assert isinstance(ref_size, int), ( 

3613 "channel axis ref (another channel axis) has to specify fixed" 

3614 + " size" 

3615 ) 

3616 generated_channel_names = [ 

3617 axis.channel_names.format(i=i) for i in range(1, ref_size + 1) 

3618 ] 

3619 axis.channel_names = generated_channel_names 

3620 

3621 if (ax_unit := getattr(axis, "unit", None)) != ( 

3622 ref_unit := getattr(valid_independent_refs[ref][1], "unit", None) 

3623 ): 

3624 raise ValueError( 

3625 "The units of an axis and its reference axis need to match, but" 

3626 + f" '{ax_unit}' != '{ref_unit}'." 

3627 ) 

3628 ref_axis = valid_independent_refs[ref][1] 

3629 if isinstance(ref_axis, BatchAxis): 

3630 raise ValueError( 

3631 f"Invalid reference axis '{ref_axis.id}' for {tensor_id}.{axis.id}" 

3632 + " (a batch axis is not allowed as reference)." 

3633 ) 

3634 

3635 if isinstance(axis, WithHalo): 

3636 min_size = axis.size.get_size(axis, ref_axis, n=0) 

3637 if (min_size - 2 * axis.halo) < 1: 

3638 raise ValueError( 

3639 f"axis {axis.id} with minimum size {min_size} is too small for halo" 

3640 + f" {axis.halo}." 

3641 ) 

3642 

3643 ref_halo = axis.halo * axis.scale / ref_axis.scale 

3644 if ref_halo != int(ref_halo): 

3645 raise ValueError( 

3646 f"Inferred halo for {'.'.join(ref)} is not an integer ({ref_halo} =" 

3647 + f" {tensor_id}.{axis.id}.halo {axis.halo}" 

3648 + f" * {tensor_id}.{axis.id}.scale {axis.scale}" 

3649 + f" / {'.'.join(ref)}.scale {ref_axis.scale})." 

3650 ) 

3651 

3652 def validate_input_tensors( 

3653 self, 

3654 sources: Sequence[NDArray[Any]] | Mapping[TensorId, NDArray[Any] | None], 

3655 *, 

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

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

3658 ) -> Mapping[TensorId, NDArray[Any] | None]: 

3659 """Check if the given input tensors match the model's input tensor descriptions. 

3660 This includes checks of tensor shapes and dtypes, but not of the actual values. 

3661 """ 

3662 if not isinstance(sources, collections.abc.Mapping): 

3663 sources = {descr.id: tensor for descr, tensor in zip(self.inputs, sources)} 

3664 

3665 tensors = { 

3666 **{descr.id: (descr, sources.get(descr.id)) for descr in self.inputs}, 

3667 **{ # outputs are required for halo 

3668 descr.id: (descr, None) for descr in self.outputs 

3669 }, 

3670 } 

3671 validate_tensors(tensors, pad_inputs=pad_inputs, crop_outputs=crop_outputs) 

3672 

3673 return sources 

3674 

3675 @model_validator(mode="after") 

3676 def _validate_test_tensors(self) -> Self: 

3677 if not get_validation_context().perform_io_checks: 

3678 return self 

3679 

3680 test_inputs = { 

3681 descr.id: ( 

3682 descr, 

3683 None if descr.test_tensor is None else load_array(descr.test_tensor), 

3684 ) 

3685 for descr in self.inputs 

3686 } 

3687 test_outputs = { 

3688 descr.id: ( 

3689 descr, 

3690 None if descr.test_tensor is None else load_array(descr.test_tensor), 

3691 ) 

3692 for descr in self.outputs 

3693 } 

3694 

3695 validate_tensors( 

3696 {**test_inputs, **test_outputs}, 

3697 tensor_origin="test_tensor", 

3698 pad_inputs="allow", 

3699 crop_outputs="allow", 

3700 ) 

3701 

3702 for rep_tol in self.config.bioimageio.reproducibility_tolerance: 

3703 if not rep_tol.absolute_tolerance: 

3704 continue 

3705 

3706 if rep_tol.output_ids: 

3707 out_arrays = { 

3708 k: v[1] for k, v in test_outputs.items() if k in rep_tol.output_ids 

3709 } 

3710 else: 

3711 out_arrays = {k: v[1] for k, v in test_outputs.items()} 

3712 

3713 for out_id, array in out_arrays.items(): 

3714 if array is None: 

3715 continue 

3716 

3717 if rep_tol.absolute_tolerance > (max_test_value := array.max()) * 0.01: 

3718 raise ValueError( 

3719 "config.bioimageio.reproducibility_tolerance.absolute_tolerance=" 

3720 + f"{rep_tol.absolute_tolerance} > 0.01*{max_test_value}" 

3721 + f" (1% of the maximum value of the test tensor '{out_id}')" 

3722 ) 

3723 

3724 return self 

3725 

3726 @model_validator(mode="after") 

3727 def _validate_tensor_references_in_proc_kwargs(self, info: ValidationInfo) -> Self: 

3728 ipt_refs = {t.id for t in self.inputs} 

3729 missing_refs = [ 

3730 k["reference_tensor"] 

3731 for k in [p.kwargs for ipt in self.inputs for p in ipt.preprocessing] 

3732 + [p.kwargs for out in self.outputs for p in out.postprocessing] 

3733 if "reference_tensor" in k 

3734 and k["reference_tensor"] is not None 

3735 and k["reference_tensor"] not in ipt_refs 

3736 ] 

3737 

3738 if missing_refs: 

3739 raise ValueError( 

3740 f"`reference_tensor`s {missing_refs} not found. Valid input tensor" 

3741 + f" references are: {ipt_refs}." 

3742 ) 

3743 

3744 return self 

3745 

3746 name: Annotated[ 

3747 str, 

3748 RestrictCharacters(string.ascii_letters + string.digits + "_+- ()"), 

3749 MinLen(5), 

3750 MaxLen(128), 

3751 warn(MaxLen(64), "Name longer than 64 characters.", INFO), 

3752 ] 

3753 """A human-readable name of this model. 

3754 It should be no longer than 64 characters 

3755 and may only contain letter, number, underscore, minus, parentheses and spaces. 

3756 We recommend to chose a name that refers to the model's task and image modality. 

3757 """ 

3758 

3759 outputs: NotEmpty[Sequence[OutputTensorDescr]] 

3760 """Describes the output tensors.""" 

3761 

3762 @field_validator("outputs", mode="after") 

3763 @classmethod 

3764 def _validate_tensor_ids( 

3765 cls, outputs: Sequence[OutputTensorDescr], info: ValidationInfo 

3766 ) -> Sequence[OutputTensorDescr]: 

3767 tensor_ids = [ 

3768 t.id for t in info.data.get("inputs", []) + info.data.get("outputs", []) 

3769 ] 

3770 duplicate_tensor_ids: list[str] = [] 

3771 seen: set[str] = set() 

3772 for t in tensor_ids: 

3773 if t in seen: 

3774 duplicate_tensor_ids.append(t) 

3775 

3776 seen.add(t) 

3777 

3778 if duplicate_tensor_ids: 

3779 raise ValueError(f"Duplicate tensor ids: {duplicate_tensor_ids}") 

3780 

3781 return outputs 

3782 

3783 @staticmethod 

3784 def _get_axes_with_parameterized_size( 

3785 io: Sequence[InputTensorDescr] | Sequence[OutputTensorDescr], 

3786 ): 

3787 return { 

3788 f"{t.id}.{a.id}": (t, a, a.size) 

3789 for t in io 

3790 for a in t.axes 

3791 if not isinstance(a, BatchAxis) and isinstance(a.size, ParameterizedSize) 

3792 } 

3793 

3794 @staticmethod 

3795 def _get_axes_with_independent_size( 

3796 io: Sequence[InputTensorDescr] | Sequence[OutputTensorDescr], 

3797 ): 

3798 return { 

3799 (t.id, a.id): (t, a, a.size) 

3800 for t in io 

3801 for a in t.axes 

3802 if not isinstance(a, BatchAxis) 

3803 and isinstance(a.size, (int, ParameterizedSize)) 

3804 } 

3805 

3806 @field_validator("outputs", mode="after") 

3807 @classmethod 

3808 def _validate_output_axes( 

3809 cls, outputs: list[OutputTensorDescr], info: ValidationInfo 

3810 ) -> list[OutputTensorDescr]: 

3811 input_size_refs = cls._get_axes_with_independent_size( 

3812 info.data.get("inputs", []) 

3813 ) 

3814 output_size_refs = cls._get_axes_with_independent_size(outputs) 

3815 

3816 for i, out in enumerate(outputs): 

3817 valid_independent_refs: dict[ 

3818 tuple[TensorId, AxisId], 

3819 tuple[TensorDescr, AnyAxis, int | ParameterizedSize], 

3820 ] = { 

3821 **{ 

3822 (out.id, a.id): (out, a, a.size) 

3823 for a in out.axes 

3824 if not isinstance(a, BatchAxis) 

3825 and isinstance(a.size, (int, ParameterizedSize)) 

3826 }, 

3827 **input_size_refs, 

3828 **output_size_refs, 

3829 } 

3830 for a, ax in enumerate(out.axes): 

3831 cls._validate_axis( 

3832 "outputs", 

3833 i, 

3834 out.id, 

3835 a, 

3836 ax, 

3837 valid_independent_refs=valid_independent_refs, 

3838 ) 

3839 

3840 return outputs 

3841 

3842 packaged_by: list[Author] = Field( 

3843 default_factory=cast(Callable[[], List[Author]], list) 

3844 ) 

3845 """The persons that have packaged and uploaded this model. 

3846 Only required if those persons differ from the `authors`.""" 

3847 

3848 parent: LinkedModel | None = None 

3849 """The model from which this model is derived, e.g. by fine-tuning the weights.""" 

3850 

3851 @model_validator(mode="after") 

3852 def _validate_parent_is_not_self(self) -> Self: 

3853 if self.parent is not None and self.parent.id == self.id: 

3854 raise ValueError("A model description may not reference itself as parent.") 

3855 

3856 return self 

3857 

3858 run_mode: Annotated[ 

3859 RunMode | None, 

3860 warn(None, "Run mode '{value}' has limited support across consumer softwares."), 

3861 ] = None 

3862 """Custom run mode for this model: for more complex prediction procedures like test time 

3863 data augmentation that currently cannot be expressed in the specification. 

3864 No standard run modes are defined yet.""" 

3865 

3866 timestamp: Datetime = Field(default_factory=Datetime.now) 

3867 """Timestamp in [ISO 8601](#https://en.wikipedia.org/wiki/ISO_8601) format 

3868 with a few restrictions listed [here](https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat). 

3869 (In Python a datetime object is valid, too).""" 

3870 

3871 training_data: Annotated[ 

3872 None | LinkedDataset | DatasetDescr | DatasetDescr02, 

3873 Field(union_mode="left_to_right"), 

3874 ] = None 

3875 """The dataset used to train this model""" 

3876 

3877 weights: Annotated[WeightsDescr, WrapSerializer(package_weights)] 

3878 """The weights for this model. 

3879 Weights can be given for different formats, but should otherwise be equivalent. 

3880 The available weight formats determine which consumers can use this model.""" 

3881 

3882 config: Config = Field(default_factory=Config.model_construct) 

3883 

3884 @model_validator(mode="after") 

3885 def _add_default_cover(self) -> Self: 

3886 if not get_validation_context().perform_io_checks or self.covers: 

3887 return self 

3888 

3889 try: 

3890 generated_covers = generate_covers( 

3891 [ 

3892 (t, load_array(t.test_tensor)) 

3893 for t in self.inputs 

3894 if t.test_tensor is not None 

3895 ], 

3896 [ 

3897 (t, load_array(t.test_tensor)) 

3898 for t in self.outputs 

3899 if t.test_tensor is not None 

3900 ], 

3901 ) 

3902 except Exception as e: 

3903 issue_warning( 

3904 "Failed to generate cover image(s): {e}", 

3905 value=self.covers, 

3906 msg_context={"e": e}, 

3907 field="covers", 

3908 ) 

3909 else: 

3910 self.covers.extend(generated_covers) 

3911 

3912 return self 

3913 

3914 def get_input_test_arrays(self) -> list[NDArray[Any]]: 

3915 return self._get_test_arrays(self.inputs) 

3916 

3917 def get_output_test_arrays(self) -> list[NDArray[Any]]: 

3918 return self._get_test_arrays(self.outputs) 

3919 

3920 @staticmethod 

3921 def _get_test_arrays( 

3922 io_descr: Sequence[InputTensorDescr] | Sequence[OutputTensorDescr], 

3923 ): 

3924 ts: list[FileDescr] = [] 

3925 for d in io_descr: 

3926 if d.test_tensor is None: 

3927 raise ValueError( 

3928 f"Failed to get test arrays: description of '{d.id}' is missing a `test_tensor`." 

3929 ) 

3930 ts.append(d.test_tensor) 

3931 

3932 data = [load_array(t) for t in ts] 

3933 assert all(isinstance(d, np.ndarray) for d in data) 

3934 return data 

3935 

3936 @staticmethod 

3937 def get_batch_size(tensor_sizes: Mapping[TensorId, Mapping[AxisId, int]]) -> int: 

3938 batch_size = 1 

3939 tensor_with_batchsize: TensorId | None = None 

3940 for tid in tensor_sizes: 

3941 for aid, s in tensor_sizes[tid].items(): 

3942 if aid != BATCH_AXIS_ID or s == 1 or s == batch_size: 

3943 continue 

3944 

3945 if batch_size != 1: 

3946 assert tensor_with_batchsize is not None 

3947 raise ValueError( 

3948 f"batch size mismatch for tensors '{tensor_with_batchsize}' ({batch_size}) and '{tid}' ({s})" 

3949 ) 

3950 

3951 batch_size = s 

3952 tensor_with_batchsize = tid 

3953 

3954 return batch_size 

3955 

3956 def get_output_tensor_sizes( 

3957 self, input_sizes: Mapping[TensorId, Mapping[AxisId, int]] 

3958 ) -> dict[TensorId, dict[AxisId, int | _DataDepSize]]: 

3959 """Returns the tensor output sizes for given **input_sizes**. 

3960 Only if **input_sizes** has a valid input shape, the tensor output size is exact. 

3961 Otherwise it might be larger than the actual (valid) output""" 

3962 batch_size = self.get_batch_size(input_sizes) 

3963 ns = self.get_ns(input_sizes) 

3964 

3965 tensor_sizes = self.get_tensor_sizes(ns, batch_size=batch_size) 

3966 return tensor_sizes.outputs 

3967 

3968 def get_ns(self, input_sizes: Mapping[TensorId, Mapping[AxisId, int]]): 

3969 """get parameter `n` for each parameterized axis 

3970 such that the valid input size is >= the given input size""" 

3971 ret: dict[tuple[TensorId, AxisId], ParameterizedSize_N] = {} 

3972 axes = {t.id: {a.id: a for a in t.axes} for t in self.inputs} 

3973 for tid in input_sizes: 

3974 for aid, s in input_sizes[tid].items(): 

3975 size_descr = axes[tid][aid].size 

3976 if isinstance(size_descr, ParameterizedSize): 

3977 ret[(tid, aid)] = size_descr.get_n(s) 

3978 elif size_descr is None or isinstance(size_descr, (int, SizeReference)): 

3979 pass 

3980 else: 

3981 assert_never(size_descr) 

3982 

3983 return ret 

3984 

3985 def get_tensor_sizes( 

3986 self, 

3987 ns: Mapping[tuple[TensorId, AxisId], ParameterizedSize_N], 

3988 batch_size: int, 

3989 max_input_shape: Mapping[TensorId, Mapping[AxisId, int]] | None = None, 

3990 ) -> _TensorSizes: 

3991 max_axis_sizes: dict[tuple[TensorId, AxisId], int] = {} 

3992 for m, this_max_axis_sizes in (max_input_shape or {}).items(): 

3993 for a, s in this_max_axis_sizes.items(): 

3994 max_axis_sizes[(m, a)] = s 

3995 

3996 axis_sizes = self.get_axis_sizes( 

3997 ns, batch_size=batch_size, max_input_shape=max_axis_sizes 

3998 ) 

3999 return _TensorSizes( 

4000 { 

4001 t: { 

4002 aa: axis_sizes.inputs[(tt, aa)] 

4003 for tt, aa in axis_sizes.inputs 

4004 if tt == t 

4005 } 

4006 for t in {tt for tt, _ in axis_sizes.inputs} 

4007 }, 

4008 { 

4009 t: { 

4010 aa: axis_sizes.outputs[(tt, aa)] 

4011 for tt, aa in axis_sizes.outputs 

4012 if tt == t 

4013 } 

4014 for t in {tt for tt, _ in axis_sizes.outputs} 

4015 }, 

4016 ) 

4017 

4018 def get_axis_sizes( 

4019 self, 

4020 ns: Mapping[tuple[TensorId, AxisId], ParameterizedSize_N], 

4021 batch_size: int | None = None, 

4022 *, 

4023 max_input_shape: Mapping[tuple[TensorId, AxisId], int] | None = None, 

4024 ) -> _AxisSizes: 

4025 """Determine input and output block shape for scale factors **ns** 

4026 of parameterized input sizes. 

4027 

4028 Args: 

4029 ns: Scale factor `n` for each axis (keyed by (tensor_id, axis_id)) 

4030 that is parameterized as `size = min + n * step`. 

4031 batch_size: The desired size of the batch dimension. 

4032 If given **batch_size** overwrites any batch size present in 

4033 **max_input_shape**. Default 1. 

4034 max_input_shape: Limits the derived block shapes. 

4035 Each axis for which the input size, parameterized by `n`, is larger 

4036 than **max_input_shape** is set to the minimal value `n_min` for which 

4037 this is still true. 

4038 Use this for small input samples or large values of **ns**. 

4039 Or simply whenever you know the full input shape. 

4040 

4041 Returns: 

4042 Resolved axis sizes for model inputs and outputs. 

4043 """ 

4044 max_input_shape = max_input_shape or {} 

4045 if batch_size is None: 

4046 for (_t_id, a_id), s in max_input_shape.items(): 

4047 if a_id == BATCH_AXIS_ID: 

4048 batch_size = s 

4049 break 

4050 else: 

4051 batch_size = 1 

4052 

4053 all_axes = { 

4054 t.id: {a.id: a for a in t.axes} for t in chain(self.inputs, self.outputs) 

4055 } 

4056 

4057 inputs: dict[tuple[TensorId, AxisId], int] = {} 

4058 outputs: dict[tuple[TensorId, AxisId], int | _DataDepSize] = {} 

4059 

4060 def get_axis_size(a: InputAxis | OutputAxis): 

4061 if isinstance(a, BatchAxis): 

4062 if (t_descr.id, a.id) in ns: 

4063 logger.warning( 

4064 "Ignoring unexpected size increment factor (n) for batch axis" 

4065 + " of tensor '{}'.", 

4066 t_descr.id, 

4067 ) 

4068 return batch_size 

4069 elif isinstance(a.size, int): 

4070 if (t_descr.id, a.id) in ns: 

4071 logger.warning( 

4072 "Ignoring unexpected size increment factor (n) for fixed size" 

4073 + " axis '{}' of tensor '{}'.", 

4074 a.id, 

4075 t_descr.id, 

4076 ) 

4077 return a.size 

4078 elif isinstance(a.size, ParameterizedSize): 

4079 if (t_descr.id, a.id) not in ns: 

4080 raise ValueError( 

4081 "Size increment factor (n) missing for parametrized axis" 

4082 + f" '{a.id}' of tensor '{t_descr.id}'." 

4083 ) 

4084 n = ns[(t_descr.id, a.id)] 

4085 s_max = max_input_shape.get((t_descr.id, a.id)) 

4086 if s_max is not None: 

4087 n = min(n, a.size.get_n(s_max)) 

4088 

4089 return a.size.get_size(n) 

4090 

4091 elif isinstance(a.size, SizeReference): 

4092 if (t_descr.id, a.id) in ns: 

4093 logger.warning( 

4094 "Ignoring unexpected size increment factor (n) for axis '{}'" 

4095 + " of tensor '{}' with size reference.", 

4096 a.id, 

4097 t_descr.id, 

4098 ) 

4099 assert not isinstance(a, BatchAxis) 

4100 ref_axis = all_axes[a.size.tensor_id][a.size.axis_id] 

4101 assert not isinstance(ref_axis, BatchAxis) 

4102 ref_key = (a.size.tensor_id, a.size.axis_id) 

4103 ref_size = inputs.get(ref_key, outputs.get(ref_key)) 

4104 assert ref_size is not None, ref_key 

4105 assert not isinstance(ref_size, _DataDepSize), ref_key 

4106 return a.size.get_size( 

4107 axis=a, 

4108 ref_axis=ref_axis, 

4109 ref_size=ref_size, 

4110 ) 

4111 elif isinstance(a.size, DataDependentSize): 

4112 if (t_descr.id, a.id) in ns: 

4113 logger.warning( 

4114 "Ignoring unexpected increment factor (n) for data dependent" 

4115 + " size axis '{}' of tensor '{}'.", 

4116 a.id, 

4117 t_descr.id, 

4118 ) 

4119 return _DataDepSize(a.size.min, a.size.max) 

4120 else: 

4121 assert_never(a.size) 

4122 

4123 # first resolve all , but the `SizeReference` input sizes 

4124 for t_descr in self.inputs: 

4125 for a in t_descr.axes: 

4126 if not isinstance(a.size, SizeReference): 

4127 s = get_axis_size(a) 

4128 assert not isinstance(s, _DataDepSize) 

4129 inputs[t_descr.id, a.id] = s 

4130 

4131 # resolve all other input axis sizes 

4132 for t_descr in self.inputs: 

4133 for a in t_descr.axes: 

4134 if isinstance(a.size, SizeReference): 

4135 s = get_axis_size(a) 

4136 assert not isinstance(s, _DataDepSize) 

4137 inputs[t_descr.id, a.id] = s 

4138 

4139 # resolve all output axis sizes 

4140 for t_descr in self.outputs: 

4141 for a in t_descr.axes: 

4142 assert not isinstance(a.size, ParameterizedSize) 

4143 s = get_axis_size(a) 

4144 outputs[t_descr.id, a.id] = s 

4145 

4146 return _AxisSizes(inputs=inputs, outputs=outputs) 

4147 

4148 @model_validator(mode="before") 

4149 @classmethod 

4150 def _convert(cls, data: dict[str, Any]) -> dict[str, Any]: 

4151 cls.convert_from_old_format_wo_validation(data) 

4152 return data 

4153 

4154 @classmethod 

4155 def convert_from_old_format_wo_validation(cls, data: dict[str, Any]) -> None: 

4156 """Convert metadata following an older format version to this classes' format 

4157 without validating the result. 

4158 """ 

4159 if ( 

4160 data.get("type") == "model" 

4161 and isinstance(fv := data.get("format_version"), str) 

4162 and fv.count(".") == 2 

4163 ): 

4164 fv_parts = fv.split(".") 

4165 if any(not p.isdigit() for p in fv_parts): 

4166 return 

4167 

4168 fv_tuple = tuple(map(int, fv_parts)) 

4169 

4170 assert cls.implemented_format_version_tuple[0:2] == (0, 5) 

4171 if fv_tuple[:2] in ((0, 3), (0, 4)): 

4172 m04 = _ModelDescr_v0_4.load(data) 

4173 if isinstance(m04, InvalidDescr): 

4174 try: 

4175 updated = _model_conv.convert_as_dict( 

4176 m04 # pyright: ignore[reportArgumentType] 

4177 ) 

4178 except Exception as e: 

4179 logger.error( 

4180 "Failed to convert from invalid model 0.4 description." 

4181 + f"\nerror: {e}" 

4182 + "\nProceeding with model 0.5 validation without conversion." 

4183 ) 

4184 updated = None 

4185 else: 

4186 updated = _model_conv.convert_as_dict(m04) 

4187 

4188 if updated is not None: 

4189 data.clear() 

4190 data.update(updated) 

4191 

4192 elif fv_tuple[:2] == (0, 5): 

4193 # bump patch version 

4194 data["format_version"] = cls.implemented_format_version 

4195 

4196 if fv_tuple[:2] in ((0, 3), (0, 4)) or ( 

4197 fv_tuple[:2] == (0, 5) and fv_tuple[2] < 11 

4198 ): 

4199 convert_plain_covers_and_docs_and_icon(data) 

4200 

4201 

4202class _ModelConv(Converter[_ModelDescr_v0_4, ModelDescr]): 

4203 def _convert( 

4204 self, src: _ModelDescr_v0_4, tgt: type[ModelDescr | dict[str, Any]] 

4205 ) -> ModelDescr | dict[str, Any]: 

4206 name = "".join( 

4207 c if c in string.ascii_letters + string.digits + "_+- ()" else " " 

4208 for c in src.name 

4209 ) 

4210 

4211 def conv_authors(auths: Sequence[_Author_v0_4] | None): 

4212 conv = ( 

4213 _author_conv.convert if TYPE_CHECKING else _author_conv.convert_as_dict 

4214 ) 

4215 return None if auths is None else [conv(a) for a in auths] 

4216 

4217 if TYPE_CHECKING: 

4218 arch_file_conv = _arch_file_conv.convert 

4219 arch_lib_conv = _arch_lib_conv.convert 

4220 else: 

4221 arch_file_conv = _arch_file_conv.convert_as_dict 

4222 arch_lib_conv = _arch_lib_conv.convert_as_dict 

4223 

4224 input_size_refs = { 

4225 ipt.name: { 

4226 a: s 

4227 for a, s in zip( 

4228 ipt.axes, 

4229 ( 

4230 ipt.shape.min 

4231 if isinstance(ipt.shape, _ParameterizedInputShape_v0_4) 

4232 else ipt.shape 

4233 ), 

4234 ) 

4235 } 

4236 for ipt in src.inputs 

4237 if ipt.shape 

4238 } 

4239 output_size_refs = { 

4240 **{ 

4241 out.name: {a: s for a, s in zip(out.axes, out.shape)} 

4242 for out in src.outputs 

4243 if not isinstance(out.shape, _ImplicitOutputShape_v0_4) 

4244 }, 

4245 **input_size_refs, 

4246 } 

4247 

4248 return tgt( 

4249 attachments=( 

4250 [] 

4251 if src.attachments is None 

4252 else [FileDescr(source=f) for f in src.attachments.files] 

4253 ), 

4254 authors=[_author_conv.convert_as_dict(a) for a in src.authors], # pyright: ignore[reportArgumentType] 

4255 cite=[{"text": c.text, "doi": c.doi, "url": c.url} for c in src.cite], # pyright: ignore[reportArgumentType] 

4256 config=src.config, # pyright: ignore[reportArgumentType] 

4257 covers=[{"source": c} for c in src.covers], # pyright: ignore[reportArgumentType] 

4258 description=src.description, 

4259 documentation={"source": src.documentation} if src.documentation else None, # pyright: ignore[reportArgumentType] 

4260 format_version="0.5.14", 

4261 git_repo=src.git_repo, # pyright: ignore[reportArgumentType] 

4262 icon={"source": src.icon} if src.icon else None, # pyright: ignore[reportArgumentType] 

4263 id=None if src.id is None else ModelId(src.id), 

4264 id_emoji=src.id_emoji, 

4265 license=src.license, # type: ignore 

4266 links=src.links, 

4267 maintainers=[_maintainer_conv.convert_as_dict(m) for m in src.maintainers], # pyright: ignore[reportArgumentType] 

4268 name=name, 

4269 tags=src.tags, 

4270 type=src.type, 

4271 uploader=src.uploader, 

4272 version=src.version, 

4273 inputs=[ # pyright: ignore[reportArgumentType] 

4274 _input_tensor_conv.convert_as_dict(ipt, tt, st, input_size_refs) 

4275 for ipt, tt, st in zip( 

4276 src.inputs, 

4277 src.test_inputs, 

4278 src.sample_inputs or [None] * len(src.test_inputs), 

4279 ) 

4280 ], 

4281 outputs=[ # pyright: ignore[reportArgumentType] 

4282 _output_tensor_conv.convert_as_dict(out, tt, st, output_size_refs) 

4283 for out, tt, st in zip( 

4284 src.outputs, 

4285 src.test_outputs, 

4286 src.sample_outputs or [None] * len(src.test_outputs), 

4287 ) 

4288 ], 

4289 parent=( 

4290 None 

4291 if src.parent is None 

4292 else LinkedModel( 

4293 id=ModelId( 

4294 str(src.parent.id) 

4295 + ( 

4296 "" 

4297 if src.parent.version_number is None 

4298 else f"/{src.parent.version_number}" 

4299 ) 

4300 ) 

4301 ) 

4302 ), 

4303 training_data=( 

4304 None 

4305 if src.training_data is None 

4306 else ( 

4307 LinkedDataset( 

4308 id=DatasetId( 

4309 str(src.training_data.id) 

4310 + ( 

4311 "" 

4312 if src.training_data.version_number is None 

4313 else f"/{src.training_data.version_number}" 

4314 ) 

4315 ) 

4316 ) 

4317 if isinstance(src.training_data, LinkedDataset02) 

4318 else src.training_data 

4319 ) 

4320 ), 

4321 packaged_by=[_author_conv.convert_as_dict(a) for a in src.packaged_by], # pyright: ignore[reportArgumentType] 

4322 run_mode=src.run_mode, 

4323 timestamp=src.timestamp, 

4324 weights=(WeightsDescr if TYPE_CHECKING else dict)( 

4325 keras_hdf5=(w := src.weights.keras_hdf5) 

4326 and (KerasHdf5WeightsDescr if TYPE_CHECKING else dict)( 

4327 authors=conv_authors(w.authors), 

4328 source=w.source, 

4329 tensorflow_version=w.tensorflow_version or Version("1.15"), 

4330 parent=w.parent, 

4331 ), 

4332 onnx=(w := src.weights.onnx) 

4333 and (OnnxWeightsDescr if TYPE_CHECKING else dict)( 

4334 source=w.source, 

4335 authors=conv_authors(w.authors), 

4336 parent=w.parent, 

4337 opset_version=w.opset_version or 15, 

4338 ), 

4339 pytorch_state_dict=(w := src.weights.pytorch_state_dict) 

4340 and (PytorchStateDictWeightsDescr if TYPE_CHECKING else dict)( 

4341 source=w.source, 

4342 authors=conv_authors(w.authors), 

4343 parent=w.parent, 

4344 architecture=( 

4345 arch_file_conv( 

4346 w.architecture, 

4347 w.architecture_sha256, 

4348 w.kwargs, 

4349 ) 

4350 if isinstance(w.architecture, _CallableFromFile_v0_4) 

4351 else arch_lib_conv(w.architecture, w.kwargs) 

4352 ), 

4353 pytorch_version=w.pytorch_version or Version("1.10"), 

4354 dependencies=( 

4355 None 

4356 if w.dependencies is None 

4357 else (FileDescr if TYPE_CHECKING else dict)( 

4358 source=cast( 

4359 FileSource, 

4360 str(deps := w.dependencies)[ 

4361 ( 

4362 len("conda:") 

4363 if str(deps).startswith("conda:") 

4364 else 0 

4365 ) : 

4366 ], 

4367 ) 

4368 ) 

4369 ), 

4370 ), 

4371 tensorflow_js=(w := src.weights.tensorflow_js) 

4372 and (TensorflowJsWeightsDescr if TYPE_CHECKING else dict)( 

4373 source=w.source, 

4374 authors=conv_authors(w.authors), 

4375 parent=w.parent, 

4376 tensorflow_version=w.tensorflow_version or Version("1.15"), 

4377 ), 

4378 tensorflow_saved_model_bundle=( 

4379 w := src.weights.tensorflow_saved_model_bundle 

4380 ) 

4381 and (TensorflowSavedModelBundleWeightsDescr if TYPE_CHECKING else dict)( 

4382 authors=conv_authors(w.authors), 

4383 parent=w.parent, 

4384 source=w.source, 

4385 tensorflow_version=w.tensorflow_version or Version("1.15"), 

4386 dependencies=( 

4387 None 

4388 if w.dependencies is None 

4389 else (FileDescr if TYPE_CHECKING else dict)( 

4390 source=cast( 

4391 FileSource, 

4392 ( 

4393 str(w.dependencies)[len("conda:") :] 

4394 if str(w.dependencies).startswith("conda:") 

4395 else str(w.dependencies) 

4396 ), 

4397 ) 

4398 ) 

4399 ), 

4400 ), 

4401 torchscript=(w := src.weights.torchscript) 

4402 and (TorchscriptWeightsDescr if TYPE_CHECKING else dict)( 

4403 source=w.source, 

4404 authors=conv_authors(w.authors), 

4405 parent=w.parent, 

4406 pytorch_version=w.pytorch_version or Version("1.10"), 

4407 ), 

4408 ), 

4409 ) 

4410 

4411 

4412_model_conv = _ModelConv(_ModelDescr_v0_4, ModelDescr) 

4413 

4414 

4415# create better cover images for 3d data and non-image outputs 

4416def generate_covers( 

4417 inputs: Sequence[tuple[InputTensorDescr, NDArray[Any]]], 

4418 outputs: Sequence[tuple[OutputTensorDescr, NDArray[Any]]], 

4419) -> list[FileDescr]: 

4420 def squeeze( 

4421 data: NDArray[Any], axes: Sequence[AnyAxis] 

4422 ) -> tuple[NDArray[Any], list[AnyAxis]]: 

4423 """apply numpy.ndarray.squeeze while keeping track of the axis descriptions remaining""" 

4424 if data.ndim != len(axes): 

4425 raise ValueError( 

4426 f"tensor shape {data.shape} does not match described axes" 

4427 + f" {[a.id for a in axes]}" 

4428 ) 

4429 

4430 axes = [deepcopy(a) for a, s in zip(axes, data.shape) if s != 1] 

4431 return data.squeeze(), axes 

4432 

4433 def normalize( 

4434 data: NDArray[Any], axis: tuple[int, ...] | None, eps: float = 1e-7 

4435 ) -> NDArray[np.float32]: 

4436 data = data.astype("float32") 

4437 data -= data.min(axis=axis, keepdims=True) 

4438 data /= data.max(axis=axis, keepdims=True) + eps 

4439 return data 

4440 

4441 def to_2d_image(data: NDArray[Any], axes: Sequence[AnyAxis]): 

4442 original_shape = data.shape 

4443 original_axes = list(axes) 

4444 data, axes = squeeze(data, axes) 

4445 

4446 # take slice fom any batch or index axis if needed 

4447 # and convert the first channel axis and take a slice from any additional channel axes 

4448 slices: tuple[slice, ...] = () 

4449 ndim = data.ndim 

4450 ndim_need = 3 if any(isinstance(a, ChannelAxis) for a in axes) else 2 

4451 has_c_axis = False 

4452 for i, a in enumerate(axes): 

4453 s = data.shape[i] 

4454 assert s > 1 

4455 if ( 

4456 isinstance(a, (BatchAxis, IndexInputAxis, IndexOutputAxis)) 

4457 and ndim > ndim_need 

4458 ): 

4459 data = data[slices + (slice(s // 2 - 1, s // 2),)] 

4460 ndim -= 1 

4461 elif isinstance(a, ChannelAxis): 

4462 if has_c_axis: 

4463 # second channel axis 

4464 data = data[slices + (slice(0, 1),)] 

4465 ndim -= 1 

4466 else: 

4467 has_c_axis = True 

4468 if s == 2: 

4469 # visualize two channels with cyan and magenta 

4470 data = np.concatenate( 

4471 [ 

4472 data[slices + (slice(1, 2),)], 

4473 data[slices + (slice(0, 1),)], 

4474 ( 

4475 data[slices + (slice(0, 1),)] 

4476 + data[slices + (slice(1, 2),)] 

4477 ) 

4478 / 2, # TODO: take maximum instead? 

4479 ], 

4480 axis=i, 

4481 ) 

4482 elif data.shape[i] == 3: 

4483 pass # visualize 3 channels as RGB 

4484 else: 

4485 # visualize first 3 channels as RGB 

4486 data = data[slices + (slice(3),)] 

4487 

4488 assert data.shape[i] == 3 

4489 

4490 slices += (slice(None),) 

4491 

4492 data, axes = squeeze(data, axes) 

4493 assert len(axes) == ndim 

4494 # take slice from z axis if needed 

4495 slices = () 

4496 if ndim > ndim_need: 

4497 for i, a in enumerate(axes): 

4498 s = data.shape[i] 

4499 if a.id == AxisId("z"): 

4500 data = data[slices + (slice(s // 2 - 1, s // 2),)] 

4501 data, axes = squeeze(data, axes) 

4502 ndim -= 1 

4503 break 

4504 

4505 slices += (slice(None),) 

4506 

4507 # take slice from any space or time axis 

4508 slices = () 

4509 

4510 for i, a in enumerate(axes): 

4511 if ndim <= ndim_need: 

4512 break 

4513 

4514 s = data.shape[i] 

4515 assert s > 1 

4516 if isinstance( 

4517 a, (SpaceInputAxis, SpaceOutputAxis, TimeInputAxis, TimeOutputAxis) 

4518 ): 

4519 data = data[slices + (slice(s // 2 - 1, s // 2),)] 

4520 ndim -= 1 

4521 

4522 slices += (slice(None),) 

4523 

4524 del slices 

4525 data, axes = squeeze(data, axes) 

4526 assert len(axes) == ndim 

4527 

4528 if (has_c_axis and ndim != 3) or (not has_c_axis and ndim != 2): 

4529 raise ValueError( 

4530 f"Failed to construct cover image from shape {original_shape} with axes {[a.id for a in original_axes]}." 

4531 ) 

4532 

4533 if not has_c_axis: 

4534 assert ndim == 2 

4535 data = np.repeat(data[:, :, None], 3, axis=2) 

4536 axes.append(ChannelAxis(channel_names=list("RGB"))) 

4537 ndim += 1 

4538 

4539 assert ndim == 3 

4540 

4541 # transpose axis order such that longest axis comes first... 

4542 axis_order: list[int] = [int(i) for i in np.argsort(list(data.shape))] 

4543 axis_order.reverse() 

4544 # ... and channel axis is last 

4545 c = next(i for i in range(3) if isinstance(axes[i], ChannelAxis)) 

4546 axis_order.append(axis_order.pop(c)) 

4547 axes = [axes[ao] for ao in axis_order] 

4548 data = data.transpose(axis_order) 

4549 

4550 # h, w = data.shape[:2] 

4551 # if h / w in (1.0 or 2.0): 

4552 # pass 

4553 # elif h / w < 2: 

4554 # TODO: enforce 2:1 or 1:1 aspect ratio for generated cover images 

4555 

4556 norm_along = ( 

4557 tuple(i for i, a in enumerate(axes) if a.type in ("space", "time")) or None 

4558 ) 

4559 # normalize the data and map to 8 bit 

4560 data = normalize(data, norm_along) 

4561 data = (data * 255).astype("uint8") 

4562 

4563 return data 

4564 

4565 def create_diagonal_split_image(im0: NDArray[Any], im1: NDArray[Any]): 

4566 assert im0.dtype == im1.dtype == np.uint8 

4567 assert im0.shape == im1.shape 

4568 assert im0.ndim == 3 

4569 N, M, C = im0.shape 

4570 assert C == 3 

4571 out = np.ones((N, M, C), dtype="uint8") 

4572 for c in range(C): 

4573 outc = np.tril(im0[..., c]) 

4574 mask = outc == 0 

4575 outc[mask] = np.triu(im1[..., c])[mask] 

4576 out[..., c] = outc 

4577 

4578 return out 

4579 

4580 if not inputs: 

4581 raise ValueError("Missing test input tensor for cover generation.") 

4582 

4583 if not outputs: 

4584 raise ValueError("Missing test output tensor for cover generation.") 

4585 

4586 ipt_descr, ipt = inputs[0] 

4587 out_descr, out = outputs[0] 

4588 

4589 ipt_img = to_2d_image(ipt, ipt_descr.axes) 

4590 out_img = to_2d_image(out, out_descr.axes) 

4591 

4592 cover_folder = Path(mkdtemp()) 

4593 if ipt_img.shape == out_img.shape: 

4594 covers = [cover_folder / "cover.png"] 

4595 imwrite(covers[0], create_diagonal_split_image(ipt_img, out_img)) 

4596 else: 

4597 covers = [cover_folder / "input.png", cover_folder / "output.png"] 

4598 imwrite(covers[0], ipt_img) 

4599 imwrite(covers[1], out_img) 

4600 

4601 return [FileDescr(source=c) for c in covers]