Coverage for src/bioimageio/core/tensor.py: 82%

336 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 15:59 +0000

1from __future__ import annotations 

2 

3import collections.abc 

4from itertools import permutations 

5from typing import ( 

6 TYPE_CHECKING, 

7 Any, 

8 Callable, 

9 Dict, 

10 Iterator, 

11 List, 

12 Literal, 

13 Mapping, 

14 Optional, 

15 Sequence, 

16 Tuple, 

17 Union, 

18 cast, 

19 get_args, 

20) 

21 

22import numpy as np 

23import pandas as pd 

24import xarray as xr 

25from loguru import logger 

26from numpy.typing import DTypeLike, NDArray 

27from typing_extensions import Self, assert_never 

28 

29from bioimageio.spec.model import v0_5 

30 

31from ._magic_tensor_ops import MagicTensorOpsMixin 

32from .axis import AxisId, AxisInfo, AxisLike, PerAxis 

33from .common import ( 

34 CropWhere, 

35 DTypeStr, 

36 PadMode, 

37 PadWhere, 

38 PadWidth, 

39 PadWidthLike, 

40 QuantileMethod, 

41 SliceInfo, 

42) 

43 

44if TYPE_CHECKING: 

45 from numpy.typing import ArrayLike, NDArray 

46 

47 

48_ScalarOrArray = Union["ArrayLike", np.generic, "NDArray[Any]"] # TODO: add "DaskArray" 

49 

50 

51def _resolve_pad_mode(mode: PadMode): 

52 constant_value = None 

53 if isinstance(mode, str): 

54 mode_name = mode 

55 elif isinstance(mode, v0_5.ConstantPadding): 

56 mode_name = mode.mode 

57 constant_value = mode.value 

58 elif isinstance( 

59 mode, (v0_5.EdgePadding, v0_5.ReflectPadding, v0_5.SymmetricPadding) 

60 ): 

61 mode_name = mode.mode 

62 else: 

63 assert_never(mode) 

64 

65 return mode_name, constant_value 

66 

67 

68# TODO: complete docstrings 

69# TODO: in the long run---with improved typing in xarray---we should probably replace `Tensor` with xr.DataArray 

70class Tensor(MagicTensorOpsMixin): 

71 """A wrapper around an xr.DataArray for better integration with bioimageio.spec 

72 and improved type annotations.""" 

73 

74 _Compatible = Union["Tensor", xr.DataArray, _ScalarOrArray] 

75 

76 def __init__( 

77 self, 

78 array: Union[NDArray[Any], xr.DataArray], 

79 dims: Sequence[Union[AxisId, AxisLike]], 

80 ) -> None: 

81 super().__init__() 

82 axes = tuple( 

83 a if isinstance(a, AxisId) else AxisInfo.create(a).id for a in dims 

84 ) 

85 if isinstance(array, xr.DataArray): 

86 self._data = array.transpose(*axes) 

87 else: 

88 self._data = xr.DataArray(array, dims=axes) 

89 

90 def __repr__(self) -> str: 

91 return f"<Tensor {repr(self._data)}>" 

92 

93 def __array__(self, dtype: DTypeLike = None): 

94 return np.asarray(self._data, dtype=dtype) 

95 

96 def __getitem__( 

97 self, 

98 key: Union[ 

99 SliceInfo, 

100 slice, 

101 int, 

102 PerAxis[Union[SliceInfo, slice, int]], 

103 Tensor, 

104 xr.DataArray, 

105 ], 

106 ) -> Self: 

107 if isinstance(key, SliceInfo): 

108 key = slice(*key) 

109 elif isinstance(key, collections.abc.Mapping): 

110 key = { 

111 a: s if isinstance(s, int) else s if isinstance(s, slice) else slice(*s) 

112 for a, s in key.items() 

113 } 

114 elif isinstance(key, Tensor): 

115 key = key._data 

116 

117 return self.__class__.from_xarray(self._data[key]) 

118 

119 def __setitem__( 

120 self, 

121 key: Union[PerAxis[Union[SliceInfo, slice]], Tensor, xr.DataArray], 

122 value: Union[Tensor, xr.DataArray, float, int], 

123 ) -> None: 

124 if isinstance(key, Tensor): 

125 key = key._data 

126 elif isinstance(key, xr.DataArray): 

127 pass 

128 else: 

129 key = {a: s if isinstance(s, slice) else slice(*s) for a, s in key.items()} 

130 

131 if isinstance(value, Tensor): 

132 value = value._data 

133 

134 self._data[key] = value 

135 

136 def __len__(self) -> int: 

137 return len(self.data) 

138 

139 def _iter(self: Any) -> Iterator[Any]: 

140 for n in range(len(self)): 

141 yield self[n] 

142 

143 def __iter__(self: Any) -> Iterator[Any]: 

144 if self.ndim == 0: 

145 raise TypeError("iteration over a 0-d array") 

146 return self._iter() 

147 

148 def _binary_op( 

149 self, 

150 other: _Compatible, 

151 f: Callable[[Any, Any], Any], 

152 reflexive: bool = False, 

153 ) -> Self: 

154 data = self._data._binary_op( # pyright: ignore[reportPrivateUsage] 

155 (other._data if isinstance(other, Tensor) else other), 

156 f, 

157 reflexive, 

158 ) 

159 return self.__class__.from_xarray(data) 

160 

161 def _inplace_binary_op( 

162 self, 

163 other: _Compatible, 

164 f: Callable[[Any, Any], Any], 

165 ) -> Self: 

166 _ = self._data._inplace_binary_op( # pyright: ignore[reportPrivateUsage] 

167 ( 

168 other_d 

169 if (other_d := getattr(other, "data")) is not None 

170 and isinstance( 

171 other_d, 

172 xr.DataArray, 

173 ) 

174 else other 

175 ), 

176 f, 

177 ) 

178 return self 

179 

180 def _unary_op(self, f: Callable[[Any], Any], *args: Any, **kwargs: Any) -> Self: 

181 data = self._data._unary_op( # pyright: ignore[reportPrivateUsage] 

182 f, *args, **kwargs 

183 ) 

184 return self.__class__.from_xarray(data) 

185 

186 @classmethod 

187 def from_xarray(cls, data_array: xr.DataArray) -> Self: 

188 """create a `Tensor` from an xarray data array 

189 

190 note for internal use: this factory method is round-trip save 

191 for any `Tensor`'s `data` property (an xarray.DataArray). 

192 """ 

193 return cls(array=data_array, dims=tuple(AxisId(d) for d in data_array.dims)) 

194 

195 @classmethod 

196 def from_numpy( 

197 cls, 

198 array: NDArray[Any], 

199 *, 

200 dims: Optional[Union[AxisLike, Sequence[AxisLike]]], 

201 ) -> Tensor: 

202 """create a `Tensor` from a numpy array 

203 

204 Args: 

205 array: the nd numpy array 

206 dims: A description of the array's axes. 

207 If None axes are guessed (which might fail and raise a ValueError.) 

208 If dims do not match array shape, permutations and singleton dimensions are tried to find a match. 

209 Raises: 

210 ValueError: if `dims` is None and dims guessing fails. 

211 """ 

212 

213 if dims is None: 

214 return cls._interprete_array_wo_known_axes(array) 

215 elif isinstance(dims, collections.abc.Sequence): 

216 dim_seq = list(dims) 

217 else: 

218 dim_seq = [dims] 

219 

220 axis_infos = [AxisInfo.create(a) for a in dim_seq] 

221 original_shape = tuple(array.shape) 

222 

223 successful_view = _get_array_view(array, axis_infos) 

224 if successful_view is None: 

225 raise ValueError( 

226 f"Array shape {original_shape} does not map to axes {dims}" 

227 ) 

228 

229 return Tensor(successful_view, dims=tuple(a.id for a in axis_infos)) 

230 

231 @property 

232 def data(self): 

233 return self._data 

234 

235 @property 

236 def dims(self): # TODO: rename to `axes`? 

237 """Tuple of dimension names associated with this tensor.""" 

238 return cast(Tuple[AxisId, ...], self._data.dims) 

239 

240 @property 

241 def dtype(self) -> DTypeStr: 

242 dt = str(self.data.dtype) # pyright: ignore[reportUnknownArgumentType] 

243 assert dt in get_args(DTypeStr) 

244 return dt # pyright: ignore[reportReturnType] 

245 

246 @property 

247 def ndim(self): 

248 """Number of tensor dimensions.""" 

249 return self._data.ndim 

250 

251 @property 

252 def shape(self): 

253 """Tuple of tensor axes lengths""" 

254 return self._data.shape 

255 

256 @property 

257 def shape_tuple(self): 

258 """Tuple of tensor axes lengths""" 

259 return self._data.shape 

260 

261 @property 

262 def size(self): 

263 """Number of elements in the tensor. 

264 

265 Equal to math.prod(tensor.shape), i.e., the product of the tensors’ dimensions. 

266 """ 

267 return self._data.size 

268 

269 @property 

270 def sizes(self): 

271 """Ordered, immutable mapping from axis ids to axis lengths.""" 

272 return cast(Mapping[AxisId, int], self.data.sizes) 

273 

274 @property 

275 def tagged_shape(self): 

276 """(alias for `sizes`) Ordered, immutable mapping from axis ids to lengths.""" 

277 return self.sizes 

278 

279 def to_numpy(self) -> NDArray[Any]: 

280 """Return the data of this tensor as a numpy array.""" 

281 return self.data.to_numpy() # pyright: ignore[reportUnknownVariableType] 

282 

283 def argmax(self) -> Mapping[AxisId, int]: 

284 ret = self._data.argmax(...) 

285 assert isinstance(ret, dict) 

286 return {cast(AxisId, k): cast(int, v.item()) for k, v in ret.items()} 

287 

288 def astype(self, dtype: DTypeStr, *, copy: bool = False): 

289 """Return tensor cast to `dtype` 

290 

291 note: if dtype is already satisfied copy if `copy`""" 

292 return self.__class__.from_xarray(self._data.astype(dtype, copy=copy)) 

293 

294 def clip(self, min: Optional[float] = None, max: Optional[float] = None): 

295 """Return a tensor whose values are limited to [min, max]. 

296 At least one of max or min must be given.""" 

297 return self.__class__.from_xarray(self._data.clip(min, max)) 

298 

299 def crop_to( 

300 self, 

301 sizes: PerAxis[int], 

302 crop_where: Union[ 

303 CropWhere, 

304 PerAxis[CropWhere], 

305 ] = "left_and_right", 

306 ) -> Self: 

307 """crop to match `sizes`""" 

308 if isinstance(crop_where, str): 

309 crop_axis_where: PerAxis[CropWhere] = {a: crop_where for a in self.dims} 

310 else: 

311 crop_axis_where = crop_where 

312 

313 slices: Dict[AxisId, SliceInfo] = {} 

314 

315 for a, s_is in self.sizes.items(): 

316 if a not in sizes or sizes[a] == s_is: 

317 pass 

318 elif sizes[a] > s_is: 

319 logger.warning( 

320 "Cannot crop axis {} of size {} to larger size {}", 

321 a, 

322 s_is, 

323 sizes[a], 

324 ) 

325 elif a not in crop_axis_where: 

326 raise ValueError( 

327 f"Don't know where to crop axis {a}, `crop_where`={crop_where}" 

328 ) 

329 else: 

330 crop_this_axis_where = crop_axis_where[a] 

331 if crop_this_axis_where == "left": 

332 slices[a] = SliceInfo(s_is - sizes[a], s_is) 

333 elif crop_this_axis_where == "right": 

334 slices[a] = SliceInfo(0, sizes[a]) 

335 elif crop_this_axis_where == "left_and_right": 

336 slices[a] = SliceInfo( 

337 start := (s_is - sizes[a]) // 2, sizes[a] + start 

338 ) 

339 else: 

340 assert_never(crop_this_axis_where) 

341 

342 return self[slices] 

343 

344 def expand_dims(self, dims: Union[Sequence[AxisId], PerAxis[int]]) -> Self: 

345 return self.__class__.from_xarray(self._data.expand_dims(dims=dims)) 

346 

347 def item( 

348 self, 

349 key: Union[ 

350 None, SliceInfo, slice, int, PerAxis[Union[SliceInfo, slice, int]] 

351 ] = None, 

352 ): 

353 """Copy a tensor element to a standard Python scalar and return it.""" 

354 if key is None: 

355 ret = self._data.item() 

356 else: 

357 ret = self[key]._data.item() 

358 

359 assert isinstance(ret, (bool, float, int)) 

360 return ret 

361 

362 def mean(self, dim: Optional[Union[AxisId, Sequence[AxisId]]] = None) -> Self: 

363 return self.__class__.from_xarray(self._data.mean(dim=dim)) 

364 

365 def pad( 

366 self, 

367 pad_width: PerAxis[PadWidthLike], 

368 mode: PadMode = "symmetric", 

369 ) -> Self: 

370 pad_width = {a: PadWidth.create(p) for a, p in pad_width.items()} 

371 mode_name, constant_value = _resolve_pad_mode(mode) 

372 return self.__class__.from_xarray( 

373 self._data.pad( 

374 pad_width=pad_width, mode=mode_name, constant_values=constant_value 

375 ) 

376 ) 

377 

378 def pad_to( 

379 self, 

380 sizes: PerAxis[int], 

381 pad_where: Union[PadWhere, PerAxis[PadWhere]] = "left_and_right", 

382 mode: PadMode = "symmetric", 

383 ) -> Self: 

384 """pad `tensor` to match `sizes`""" 

385 if isinstance(pad_where, str): 

386 pad_axis_where: PerAxis[PadWhere] = {a: pad_where for a in self.dims} 

387 else: 

388 pad_axis_where = pad_where 

389 

390 pad_width: Dict[AxisId, PadWidth] = {} 

391 for a, s_is in self.sizes.items(): 

392 if a not in sizes or sizes[a] == s_is: 

393 pad_width[a] = PadWidth(0, 0) 

394 elif s_is > sizes[a]: 

395 pad_width[a] = PadWidth(0, 0) 

396 logger.warning( 

397 "Cannot pad axis {} of size {} to smaller size {}", 

398 a, 

399 s_is, 

400 sizes[a], 

401 ) 

402 elif a not in pad_axis_where: 

403 raise ValueError( 

404 f"Don't know where to pad axis {a}, `pad_where`={pad_where}" 

405 ) 

406 else: 

407 pad_this_axis_where = pad_axis_where[a] 

408 d = sizes[a] - s_is 

409 if pad_this_axis_where == "left": 

410 pad_width[a] = PadWidth(d, 0) 

411 elif pad_this_axis_where == "right": 

412 pad_width[a] = PadWidth(0, d) 

413 elif pad_this_axis_where == "left_and_right": 

414 pad_width[a] = PadWidth(left := d // 2, d - left) 

415 else: 

416 assert_never(pad_this_axis_where) 

417 

418 return self.pad(pad_width, mode) 

419 

420 def quantile( 

421 self, 

422 q: Union[float, Sequence[float]], 

423 dim: Optional[Union[AxisId, Sequence[AxisId]]] = None, 

424 method: QuantileMethod = "linear", 

425 ) -> Self: 

426 assert ( 

427 isinstance(q, (float, int)) 

428 and q >= 0.0 

429 or not isinstance(q, (float, int)) 

430 and all(qq >= 0.0 for qq in q) 

431 ) 

432 assert ( 

433 isinstance(q, (float, int)) 

434 and q <= 1.0 

435 or not isinstance(q, (float, int)) 

436 and all(qq <= 1.0 for qq in q) 

437 ) 

438 assert dim is None or ( 

439 (quantile_dim := AxisId("quantile")) != dim and quantile_dim not in set(dim) 

440 ) 

441 return self.__class__.from_xarray( 

442 self._data.quantile(q, dim=dim, method=method) 

443 ) 

444 

445 def resize_to( 

446 self, 

447 sizes: PerAxis[int], 

448 *, 

449 pad_where: Union[ 

450 PadWhere, 

451 PerAxis[PadWhere], 

452 ] = "left_and_right", 

453 crop_where: Union[ 

454 CropWhere, 

455 PerAxis[CropWhere], 

456 ] = "left_and_right", 

457 pad_mode: PadMode = "symmetric", 

458 ): 

459 """return cropped/padded tensor with `sizes`""" 

460 crop_to_sizes: Dict[AxisId, int] = {} 

461 pad_to_sizes: Dict[AxisId, int] = {} 

462 new_axes = dict(sizes) 

463 for a, s_is in self.sizes.items(): 

464 a = AxisId(str(a)) 

465 _ = new_axes.pop(a, None) 

466 if a not in sizes or sizes[a] == s_is: 

467 pass 

468 elif s_is > sizes[a]: 

469 crop_to_sizes[a] = sizes[a] 

470 else: 

471 pad_to_sizes[a] = sizes[a] 

472 

473 tensor = self 

474 if crop_to_sizes: 

475 tensor = tensor.crop_to(crop_to_sizes, crop_where=crop_where) 

476 

477 if pad_to_sizes: 

478 tensor = tensor.pad_to(pad_to_sizes, pad_where=pad_where, mode=pad_mode) 

479 

480 if new_axes: 

481 tensor = tensor.expand_dims(new_axes) 

482 

483 return tensor 

484 

485 def std(self, dim: Optional[Union[AxisId, Sequence[AxisId]]] = None) -> Self: 

486 return self.__class__.from_xarray(self._data.std(dim=dim)) 

487 

488 def sum(self, dim: Optional[Union[AxisId, Sequence[AxisId]]] = None) -> Self: 

489 """Reduce this Tensor's data by applying sum along some dimension(s).""" 

490 return self.__class__.from_xarray(self._data.sum(dim=dim)) 

491 

492 def assign_batch_multi_index(self, multi_index: "pd.MultiIndex") -> Self: 

493 """Set the batch multi-index for this tensor. 

494 

495 Args: 

496 multi_index: The multi-index to set. 

497 """ 

498 if AxisId("batch") not in self.dims: 

499 raise ValueError( 

500 "Cannot set batch multi-index on a tensor without a 'batch' axis." 

501 ) 

502 

503 return self.__class__.from_xarray( 

504 self._data.assign_coords({AxisId("batch"): multi_index}) 

505 ) 

506 

507 def unstack_batch_multi_index( 

508 self, *, errors: Literal["raise", "ignore"] = "raise" 

509 ) -> Self: 

510 """Unstack the batch multi-index of this tensor. 

511 

512 Returns: 

513 A new tensor with the batch multi-index unstacked into separate axes. 

514 """ 

515 if AxisId("batch") not in self.dims: 

516 if errors == "raise": 

517 raise ValueError( 

518 "Cannot unstack batch multi-index on a tensor without a 'batch' axis." 

519 ) 

520 elif errors == "ignore": 

521 return self 

522 else: 

523 assert_never(errors) 

524 

525 if not isinstance(self._data.indexes.get(AxisId("batch")), pd.MultiIndex): 

526 if errors == "raise": 

527 raise ValueError( 

528 "Cannot unstack batch multi-index on a tensor whose 'batch' axis does not have a MultiIndex." 

529 ) 

530 elif errors == "ignore": 

531 return self 

532 else: 

533 assert_never(errors) 

534 

535 old_dims = self.dims 

536 array = self._data.unstack(AxisId("batch")) 

537 added_dims = [AxisId(d) for d in array.dims if d not in self._data.dims] 

538 

539 # restore expected axis order, replace batch dim with added dims 

540 new_dims: List[AxisId] = [] 

541 for d in old_dims: 

542 if d in array.dims: 

543 new_dims.append(d) 

544 elif d == AxisId("batch"): 

545 new_dims.extend(added_dims) 

546 else: 

547 raise ValueError(f"Expected axis {d} not found in unstacked array.") 

548 

549 array = array.transpose(*new_dims) 

550 if AxisId("original_batch") in array.dims: 

551 array = array.rename({AxisId("original_batch"): AxisId("batch")}) 

552 

553 return self.__class__.from_xarray(array) 

554 

555 def transpose( 

556 self, 

557 axes: Sequence[AxisId], 

558 *, 

559 extra_dims: Literal[ 

560 "raise", "squeeze", "stack", "squeeze_or_stack" 

561 ] = "squeeze", 

562 missing_dims: Literal[ 

563 "raise", "expand", "unstack", "unstack_or_expand" 

564 ] = "unstack_or_expand", 

565 ) -> Self: 

566 """Return a transposed tensor, missing axes are expanded (if `unstack_missing_dims_from_batch` is False) or unstacked from batch (if `unstack_missing_dims_from_batch` is True), extra axes are stacked to batch (if `stack_extra_dims_to_batch` is True). Additional axes raise (if `stack_extra_dims_to_batch` is True). 

567 

568 Args: 

569 axes: The desired tensor axes 

570 extra_dims: 

571 Extra dimensions are any dimensions in the tensor that are not specified in `axes`. 

572 If "raise", any extra dimensions will raise an error. 

573 If "squeeze", any extra singleton dimensions will be squeezed, non-singleton dimensions will raise an error. 

574 If "stack", any extra dimensions will be stacked to the batch dimension. Such a stacked batch dimension then has a multi-index that can be unstacked using `Tensor.unstack_batch_multi_index()`. 

575 If "squeeze_or_stack", any extra singleton dimensions will be squeezed, non-singleton dimensions will be stacked to the batch dimension. 

576 missing_dims: 

577 Missing dimensions are any dimensions specified in `axes` that are not present in the tensor. 

578 If "raise", any missing dimensions will raise an error. 

579 If "expand", any missing dimensions will be added as singleton dimensions. 

580 If "unstack", any missing dimensions will be unstacked from the batch dimension. For this option a batch dimension with a multi-index must be present from previous stacking operations or assigned by `Tensor.assign_batch_multi_index()`. 

581 If "unstack_or_expand", any missing dimensions will be unstacked from the batch dimension if it has a multi-index, otherwise they will be added as singleton dimensions. 

582 """ 

583 array = self._data 

584 

585 unhandled_missing_dims = [a for a in axes if a not in array.dims] 

586 if unhandled_missing_dims and missing_dims == "raise": 

587 raise ValueError(f"Found missing dimensions {unhandled_missing_dims}.") 

588 

589 unstack_error = None 

590 if unhandled_missing_dims and missing_dims in ("unstack", "unstack_or_expand"): 

591 lets_unstack = AxisId("batch") in array.dims 

592 if not lets_unstack: 

593 unstack_error = f"Missing dimensions {unhandled_missing_dims} found, but 'batch' axis is not in the tensor. Cannot unstack missing dimensions from batch." 

594 if missing_dims == "unstack": 

595 raise ValueError(unstack_error) 

596 

597 if lets_unstack and not isinstance( 

598 array.indexes.get(AxisId("batch")), pd.MultiIndex 

599 ): 

600 lets_unstack = False 

601 unstack_error = f"Missing dimensions {unhandled_missing_dims} found, but 'batch' axis does not have a MultiIndex. Cannot unstack missing dimensions from non-multi-index batch." 

602 if missing_dims == "unstack": 

603 raise ValueError(unstack_error) 

604 else: 

605 lets_unstack = False 

606 

607 if lets_unstack: 

608 array = array.unstack(AxisId("batch")) 

609 

610 if AxisId("original_batch") in array.dims: 

611 if AxisId("batch") in axes: 

612 array = array.rename({AxisId("original_batch"): AxisId("batch")}) 

613 else: 

614 array = array.squeeze(AxisId("original_batch")) 

615 

616 unhandled_missing_dims = [a for a in axes if a not in array.dims] 

617 

618 if unhandled_missing_dims and missing_dims in ("expand", "unstack_or_expand"): 

619 array = array.expand_dims(unhandled_missing_dims) 

620 unhandled_missing_dims = [] 

621 

622 if unhandled_missing_dims: 

623 if unstack_error is not None: 

624 raise ValueError(unstack_error) 

625 

626 raise ValueError(f"Missing dimensions {unhandled_missing_dims}.") 

627 

628 unhandled_extra_dims = [a for a in array.dims if a not in axes] 

629 

630 if unhandled_extra_dims and extra_dims == "raise": 

631 raise ValueError(f"Found extra dimensions {unhandled_extra_dims}.") 

632 

633 if unhandled_extra_dims and extra_dims in ("squeeze", "squeeze_or_stack"): 

634 for d in list(unhandled_extra_dims): 

635 if array.sizes[d] == 1: 

636 array = array.squeeze(d) 

637 unhandled_extra_dims.remove(d) 

638 elif extra_dims == "squeeze": 

639 raise ValueError( 

640 f"Extra dimension {d} found but stack_extra_dims_to_batch is False and the dimension is not a singleton." 

641 ) 

642 

643 if unhandled_extra_dims and extra_dims in ("stack", "squeeze_or_stack"): 

644 if AxisId("batch") not in axes: 

645 raise ValueError( 

646 f"Extra dimensions {unhandled_extra_dims} found but 'batch' axis is not in the desired axes {axes}." 

647 + " Cannot stack extra dimensions to batch." 

648 ) 

649 

650 if AxisId("batch") in array.dims: 

651 array = array.rename({AxisId("batch"): AxisId("original_batch")}) 

652 unhandled_extra_dims.insert(0, AxisId("original_batch")) 

653 

654 array = array.stack({AxisId("batch"): unhandled_extra_dims}) 

655 unhandled_extra_dims = [] 

656 

657 if unhandled_extra_dims: 

658 raise ValueError( 

659 f"Non-singleton extra dimensions {unhandled_extra_dims} found, but `extra_dims` not in ('stack', 'squeeze_or_stack')." 

660 ) 

661 

662 # transpose to the correct axis order 

663 return self.__class__.from_xarray(array.transpose(*axes)) 

664 

665 def var(self, dim: Optional[Union[AxisId, Sequence[AxisId]]] = None) -> Self: 

666 return self.__class__.from_xarray(self._data.var(dim=dim)) 

667 

668 @classmethod 

669 def _interprete_array_wo_known_axes(cls, array: NDArray[Any]): 

670 ndim = array.ndim 

671 if ndim == 2: 

672 current_axes = ( 

673 v0_5.SpaceInputAxis(id=v0_5.AxisId("y"), size=array.shape[0]), 

674 v0_5.SpaceInputAxis(id=v0_5.AxisId("x"), size=array.shape[1]), 

675 ) 

676 elif ndim == 3 and any(s <= 3 for s in array.shape): 

677 current_axes = ( 

678 v0_5.ChannelAxis( 

679 channel_names=[ 

680 v0_5.Identifier(f"channel{i}") for i in range(array.shape[0]) 

681 ] 

682 ), 

683 v0_5.SpaceInputAxis(id=v0_5.AxisId("y"), size=array.shape[1]), 

684 v0_5.SpaceInputAxis(id=v0_5.AxisId("x"), size=array.shape[2]), 

685 ) 

686 elif ndim == 3: 

687 current_axes = ( 

688 v0_5.SpaceInputAxis(id=v0_5.AxisId("z"), size=array.shape[0]), 

689 v0_5.SpaceInputAxis(id=v0_5.AxisId("y"), size=array.shape[1]), 

690 v0_5.SpaceInputAxis(id=v0_5.AxisId("x"), size=array.shape[2]), 

691 ) 

692 elif ndim == 4: 

693 current_axes = ( 

694 v0_5.ChannelAxis( 

695 channel_names=[ 

696 v0_5.Identifier(f"channel{i}") for i in range(array.shape[0]) 

697 ] 

698 ), 

699 v0_5.SpaceInputAxis(id=v0_5.AxisId("z"), size=array.shape[1]), 

700 v0_5.SpaceInputAxis(id=v0_5.AxisId("y"), size=array.shape[2]), 

701 v0_5.SpaceInputAxis(id=v0_5.AxisId("x"), size=array.shape[3]), 

702 ) 

703 elif ndim == 5: 

704 current_axes = ( 

705 v0_5.BatchAxis(), 

706 v0_5.ChannelAxis( 

707 channel_names=[ 

708 v0_5.Identifier(f"channel{i}") for i in range(array.shape[1]) 

709 ] 

710 ), 

711 v0_5.SpaceInputAxis(id=v0_5.AxisId("z"), size=array.shape[2]), 

712 v0_5.SpaceInputAxis(id=v0_5.AxisId("y"), size=array.shape[3]), 

713 v0_5.SpaceInputAxis(id=v0_5.AxisId("x"), size=array.shape[4]), 

714 ) 

715 else: 

716 raise ValueError(f"Could not guess an axis mapping for {array.shape}") 

717 

718 return cls(array, dims=tuple(a.id for a in current_axes)) 

719 

720 

721def _add_singletons(arr: NDArray[Any], axis_infos: Sequence[AxisInfo]): 

722 if len(arr.shape) > len(axis_infos): 

723 # remove singletons 

724 for i, s in enumerate(arr.shape): 

725 if s == 1: 

726 arr = np.take(arr, 0, axis=i) 

727 if len(arr.shape) == len(axis_infos): 

728 break 

729 

730 # add singletons if nececsary 

731 for i, a in enumerate(axis_infos): 

732 if len(arr.shape) >= len(axis_infos): 

733 break 

734 

735 if a.size.min == 1: 

736 arr = np.expand_dims(arr, i) 

737 

738 return arr 

739 

740 

741def _get_array_view( 

742 original_array: NDArray[Any], axis_infos: Sequence[AxisInfo] 

743) -> Optional[NDArray[Any]]: 

744 perms = list(permutations(range(len(original_array.shape)))) 

745 

746 for perm in perms: 

747 view = original_array.transpose(perm) 

748 view = _add_singletons(view, axis_infos) 

749 if len(view.shape) != len(axis_infos): 

750 return None 

751 

752 for s, a in zip(view.shape, axis_infos): 

753 if ( 

754 s < a.size.min 

755 or (a.size.max is not None and s > a.size.max) 

756 or (a.size.step is not None and (s - a.size.min) % a.size.step != 0) 

757 ): 

758 break 

759 else: 

760 return view 

761 

762 return None