ngio 0.5.0__py3-none-any.whl → 0.5.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. ngio/__init__.py +2 -5
  2. ngio/common/__init__.py +6 -11
  3. ngio/common/_masking_roi.py +54 -34
  4. ngio/common/_pyramid.py +87 -321
  5. ngio/common/_roi.py +330 -258
  6. ngio/experimental/iterators/_feature.py +3 -3
  7. ngio/experimental/iterators/_rois_utils.py +11 -10
  8. ngio/hcs/_plate.py +136 -192
  9. ngio/images/_abstract_image.py +35 -539
  10. ngio/images/_create.py +283 -0
  11. ngio/images/_create_synt_container.py +43 -40
  12. ngio/images/_image.py +251 -517
  13. ngio/images/_label.py +172 -249
  14. ngio/images/_masked_image.py +2 -2
  15. ngio/images/_ome_zarr_container.py +241 -644
  16. ngio/io_pipes/_io_pipes.py +9 -9
  17. ngio/io_pipes/_io_pipes_masked.py +7 -7
  18. ngio/io_pipes/_io_pipes_roi.py +6 -6
  19. ngio/io_pipes/_io_pipes_types.py +3 -3
  20. ngio/io_pipes/_match_shape.py +8 -6
  21. ngio/io_pipes/_ops_slices_utils.py +5 -8
  22. ngio/ome_zarr_meta/__init__.py +18 -29
  23. ngio/ome_zarr_meta/_meta_handlers.py +708 -392
  24. ngio/ome_zarr_meta/ngio_specs/__init__.py +0 -4
  25. ngio/ome_zarr_meta/ngio_specs/_axes.py +51 -152
  26. ngio/ome_zarr_meta/ngio_specs/_dataset.py +22 -13
  27. ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py +91 -129
  28. ngio/ome_zarr_meta/ngio_specs/_ngio_image.py +68 -57
  29. ngio/ome_zarr_meta/v04/__init__.py +1 -5
  30. ngio/ome_zarr_meta/v04/{_v04_spec.py → _v04_spec_utils.py} +85 -54
  31. ngio/ome_zarr_meta/v05/__init__.py +1 -5
  32. ngio/ome_zarr_meta/v05/{_v05_spec.py → _v05_spec_utils.py} +87 -64
  33. ngio/resources/__init__.py +1 -1
  34. ngio/resources/resource_model.py +1 -1
  35. ngio/tables/_tables_container.py +27 -85
  36. ngio/tables/backends/_anndata.py +8 -58
  37. ngio/tables/backends/_anndata_utils.py +6 -1
  38. ngio/tables/backends/_csv.py +19 -3
  39. ngio/tables/backends/_json.py +13 -10
  40. ngio/tables/backends/_non_zarr_backends.py +196 -0
  41. ngio/tables/backends/_parquet.py +31 -3
  42. ngio/tables/v1/_roi_table.py +27 -44
  43. ngio/utils/__init__.py +12 -8
  44. ngio/utils/_datasets.py +0 -6
  45. ngio/utils/_logger.py +50 -0
  46. ngio/utils/_zarr_utils.py +250 -292
  47. {ngio-0.5.0.dist-info → ngio-0.5.0a1.dist-info}/METADATA +6 -13
  48. ngio-0.5.0a1.dist-info/RECORD +88 -0
  49. {ngio-0.5.0.dist-info → ngio-0.5.0a1.dist-info}/WHEEL +1 -1
  50. ngio/images/_create_utils.py +0 -406
  51. ngio/tables/backends/_py_arrow_backends.py +0 -222
  52. ngio/utils/_cache.py +0 -48
  53. ngio-0.5.0.dist-info/RECORD +0 -88
  54. {ngio-0.5.0.dist-info → ngio-0.5.0a1.dist-info}/licenses/LICENSE +0 -0
ngio/common/_roi.py CHANGED
@@ -1,19 +1,20 @@
1
1
  """Region of interest (ROI) metadata.
2
2
 
3
- These are the interfaces between the ROI tables / masking ROI tables and
3
+ These are the interfaces bwteen the ROI tables / masking ROI tables and
4
4
  the ImageLikeHandler.
5
5
  """
6
6
 
7
- from collections.abc import Callable, Mapping
8
- from typing import Literal, Self
7
+ from typing import TypeVar
8
+ from warnings import warn
9
9
 
10
- from pydantic import BaseModel, ConfigDict, Field, field_validator
10
+ from pydantic import BaseModel, ConfigDict
11
11
 
12
- from ngio.ome_zarr_meta import PixelSize
12
+ from ngio.common._dimensions import Dimensions
13
+ from ngio.ome_zarr_meta.ngio_specs import DefaultSpaceUnit, PixelSize, SpaceUnits
13
14
  from ngio.utils import NgioValueError
14
15
 
15
16
 
16
- def world_to_pixel(value: float, pixel_size: float, eps: float = 1e-6) -> float:
17
+ def _world_to_raster(value: float, pixel_size: float, eps: float = 1e-6) -> float:
17
18
  raster_value = value / pixel_size
18
19
 
19
20
  # If the value is very close to an integer, round it
@@ -25,291 +26,362 @@ def world_to_pixel(value: float, pixel_size: float, eps: float = 1e-6) -> float:
25
26
  return raster_value
26
27
 
27
28
 
28
- def pixel_to_world(value: float, pixel_size: float) -> float:
29
- return value * pixel_size
29
+ def _to_raster(value: float, length: float, pixel_size: float) -> tuple[float, float]:
30
+ """Convert to raster coordinates."""
31
+ raster_value = _world_to_raster(value, pixel_size)
32
+ raster_length = _world_to_raster(length, pixel_size)
33
+ return raster_value, raster_length
30
34
 
31
35
 
32
- def _join_roi_names(name1: str | None, name2: str | None) -> str | None:
33
- if name1 is not None and name2 is not None:
34
- if name1 == name2:
35
- return name1
36
- return f"{name1}:{name2}"
37
- return name1 or name2
38
-
39
-
40
- def _join_roi_labels(label1: int | None, label2: int | None) -> int | None:
41
- if label1 is not None and label2 is not None:
42
- if label1 == label2:
43
- return label1
44
- raise NgioValueError("Cannot join ROIs with different labels")
45
- return label1 or label2
46
-
47
-
48
- class RoiSlice(BaseModel):
49
- axis_name: str
50
- start: float | None = Field(default=None)
51
- length: float | None = Field(default=None, ge=0)
52
-
53
- model_config = ConfigDict(extra="forbid")
54
-
55
- @classmethod
56
- def _from_slice(
57
- cls,
58
- axis_name: str,
59
- selection: slice,
60
- ) -> "RoiSlice":
61
- start = selection.start
62
- length = (
63
- None
64
- if selection.stop is None or selection.start is None
65
- else selection.stop - selection.start
66
- )
67
- return cls(axis_name=axis_name, start=start, length=length)
68
-
69
- @classmethod
70
- def from_value(
71
- cls,
72
- axis_name: str,
73
- value: float | tuple[float | None, float | None] | slice,
74
- ) -> "RoiSlice":
75
- if isinstance(value, slice):
76
- return cls._from_slice(axis_name=axis_name, selection=value)
77
- elif isinstance(value, tuple):
78
- return cls(axis_name=axis_name, start=value[0], length=value[1])
79
- elif isinstance(value, int | float):
80
- return cls(axis_name=axis_name, start=value, length=1)
81
- else:
82
- raise TypeError(f"Unsupported type for slice value: {type(value)}")
36
+ def _to_slice(start: float | None, length: float | None) -> slice:
37
+ if length is not None:
38
+ assert start is not None
39
+ end = start + length
40
+ else:
41
+ end = None
42
+ return slice(start, end)
83
43
 
84
- def __repr__(self) -> str:
85
- return f"{self.axis_name}: {self.start}->{self.end}"
86
44
 
87
- @property
88
- def end(self) -> float | None:
89
- if self.start is None or self.length is None:
90
- return None
91
- return self.start + self.length
45
+ def _raster_to_world(value: int | float, pixel_size: float) -> float:
46
+ """Convert to world coordinates."""
47
+ return value * pixel_size
92
48
 
93
- def to_slice(self) -> slice:
94
- return slice(self.start, self.end)
95
49
 
96
- def _is_compatible(self, other: "RoiSlice", msg: str) -> None:
97
- if self.axis_name != other.axis_name:
98
- raise NgioValueError(
99
- f"{msg}: Cannot operate on RoiSlices with different axis names"
100
- )
50
+ T = TypeVar("T", int, float)
101
51
 
102
- def union(self, other: "RoiSlice") -> "RoiSlice":
103
- self._is_compatible(other, "RoiSlice union failed")
104
- start = min(self.start or 0, other.start or 0)
105
- end = max(self.end or float("inf"), other.end or float("inf"))
106
- length = end - start if end > start else 0
107
- if length == float("inf"):
108
- length = None
109
- return RoiSlice(axis_name=self.axis_name, start=start, length=length)
110
-
111
- def intersection(self, other: "RoiSlice") -> "RoiSlice | None":
112
- self._is_compatible(other, "RoiSlice intersection failed")
113
- start = max(self.start or 0, other.start or 0)
114
- end = min(self.end or float("inf"), other.end or float("inf"))
115
- if end <= start:
116
- # No intersection
117
- return None
118
- length = end - start
119
- if length == float("inf"):
120
- length = None
121
- return RoiSlice(axis_name=self.axis_name, start=start, length=length)
122
-
123
- def to_world(self, pixel_size: float) -> "RoiSlice":
124
- start = (
125
- pixel_to_world(self.start, pixel_size) if self.start is not None else None
126
- )
127
- length = (
128
- pixel_to_world(self.length, pixel_size) if self.length is not None else None
129
- )
130
- return RoiSlice(axis_name=self.axis_name, start=start, length=length)
131
52
 
132
- def to_pixel(self, pixel_size: float) -> "RoiSlice":
133
- start = (
134
- world_to_pixel(self.start, pixel_size) if self.start is not None else None
135
- )
136
- length = (
137
- world_to_pixel(self.length, pixel_size) if self.length is not None else None
138
- )
139
- return RoiSlice(axis_name=self.axis_name, start=start, length=length)
53
+ class GenericRoi(BaseModel):
54
+ """A generic Region of Interest (ROI) model."""
140
55
 
141
- def zoom(self, zoom_factor: float = 1.0) -> "RoiSlice":
142
- if zoom_factor <= 0:
143
- raise NgioValueError("Zoom factor must be greater than 0")
144
- zoom_factor -= 1.0
145
- if self.length is None:
146
- return self
56
+ name: str | None = None
57
+ x: float
58
+ y: float
59
+ z: float | None = None
60
+ t: float | None = None
61
+ x_length: float
62
+ y_length: float
63
+ z_length: float | None = None
64
+ t_length: float | None = None
65
+ label: int | None = None
66
+ unit: SpaceUnits | str | None = None
147
67
 
148
- diff_length = self.length * zoom_factor
149
- length = self.length + diff_length
150
- start = max((self.start or 0) - (diff_length / 2), 0)
151
- return RoiSlice(axis_name=self.axis_name, start=start, length=length)
68
+ model_config = ConfigDict(extra="allow")
152
69
 
70
+ def intersection(self, other: "GenericRoi") -> "GenericRoi | None":
71
+ """Calculate the intersection of this ROI with another ROI."""
72
+ return roi_intersection(self, other)
153
73
 
154
- class Roi(BaseModel):
155
- name: str | None
156
- slices: list[RoiSlice] = Field(min_length=2)
157
- label: int | None = Field(default=None, ge=0)
158
- space: Literal["world", "pixel"] = "world"
74
+ def _nice_str(self) -> str:
75
+ if self.t is not None:
76
+ t_start = self.t
77
+ else:
78
+ t_start = None
79
+ if self.t_length is not None and t_start is not None:
80
+ t_end = t_start + self.t_length
81
+ else:
82
+ t_end = None
159
83
 
160
- model_config = ConfigDict(extra="allow")
84
+ t_str = f"t={t_start}->{t_end}"
161
85
 
162
- @field_validator("slices")
163
- @classmethod
164
- def validate_no_duplicate_axes(cls, v: list[RoiSlice]) -> list[RoiSlice]:
165
- axis_names = [s.axis_name for s in v]
166
- if len(axis_names) != len(set(axis_names)):
167
- raise NgioValueError("Roi slices must have unique axis names")
168
- return v
169
-
170
- def _nice_repr__(self) -> str:
171
- slices_repr = ", ".join(repr(s) for s in self.slices)
172
- if self.label is None:
173
- label_str = ""
86
+ if self.z is not None:
87
+ z_start = self.z
174
88
  else:
175
- label_str = f", label={self.label}"
176
-
177
- if self.name is None:
178
- name_str = ""
89
+ z_start = None
90
+ if self.z_length is not None and z_start is not None:
91
+ z_end = z_start + self.z_length
179
92
  else:
180
- name_str = f"name={self.name}, "
181
- return f"Roi({name_str}{slices_repr}{label_str}, space={self.space})"
182
-
183
- @classmethod
184
- def from_values(
185
- cls,
186
- slices: Mapping[str, float | tuple[float | None, float | None] | slice],
187
- name: str | None,
188
- label: int | None = None,
189
- space: Literal["world", "pixel"] = "world",
190
- **kwargs,
191
- ) -> Self:
192
- _slices = []
193
- for axis, _slice in slices.items():
194
- _slices.append(RoiSlice.from_value(axis_name=axis, value=_slice))
195
- return cls.model_construct(
196
- name=name, slices=_slices, label=label, space=space, **kwargs
197
- )
93
+ z_end = None
94
+ z_str = f"z={z_start}->{z_end}"
198
95
 
199
- def get(self, axis_name: str) -> RoiSlice | None:
200
- for roi_slice in self.slices:
201
- if roi_slice.axis_name == axis_name:
202
- return roi_slice
203
- return None
96
+ y_str = f"y={self.y}->{self.y + self.y_length}"
97
+ x_str = f"x={self.x}->{self.x + self.x_length}"
98
+
99
+ if self.label is not None:
100
+ label_str = f", label={self.label}"
101
+ else:
102
+ label_str = ""
103
+ cls_name = self.__class__.__name__
104
+ return f"{cls_name}({t_str}, {z_str}, {y_str}, {x_str}{label_str})"
204
105
 
205
106
  def get_name(self) -> str:
107
+ """Get the name of the ROI, or a default if not set."""
206
108
  if self.name is not None:
207
109
  return self.name
208
- if self.label is not None:
209
- return str(self.label)
210
- return self._nice_repr__()
211
-
212
- @staticmethod
213
- def _apply_sym_ops(
214
- self_slices: list[RoiSlice],
215
- other_slices: list[RoiSlice],
216
- op: Callable[[RoiSlice, RoiSlice], RoiSlice | None],
217
- ) -> list[RoiSlice] | None:
218
- self_axis_dict = {s.axis_name: s for s in self_slices}
219
- other_axis_dict = {s.axis_name: s for s in other_slices}
220
- common_axis_names = self_axis_dict.keys() | other_axis_dict.keys()
221
- new_slices = []
222
- for axis_name in common_axis_names:
223
- slice_a = self_axis_dict.get(axis_name)
224
- slice_b = other_axis_dict.get(axis_name)
225
- if slice_a is not None and slice_b is not None:
226
- result = op(slice_a, slice_b)
227
- if result is None:
228
- return None
229
- new_slices.append(result)
230
- elif slice_a is not None:
231
- new_slices.append(slice_a)
232
- elif slice_b is not None:
233
- new_slices.append(slice_b)
234
- return new_slices
235
-
236
- def intersection(self, other: Self) -> Self | None:
237
- if self.space != other.space:
238
- raise NgioValueError(
239
- "Roi intersection failed: One ROI is in pixel space and the "
240
- "other in world space"
241
- )
110
+ return self._nice_str()
242
111
 
243
- out_slices = self._apply_sym_ops(
244
- self.slices, other.slices, op=lambda a, b: a.intersection(b)
112
+ def __repr__(self) -> str:
113
+ return self._nice_str()
114
+
115
+ def __str__(self) -> str:
116
+ return self._nice_str()
117
+
118
+ def to_slicing_dict(self, pixel_size: PixelSize) -> dict[str, slice]:
119
+ raise NotImplementedError
120
+
121
+
122
+ def _1d_intersection(
123
+ a: T | None, a_length: T | None, b: T | None, b_length: T | None
124
+ ) -> tuple[T | None, T | None]:
125
+ """Calculate the intersection of two 1D intervals."""
126
+ if a is None:
127
+ if b is not None and b_length is not None:
128
+ return b, b_length
129
+ return None, None
130
+ if b is None:
131
+ if a is not None and a_length is not None:
132
+ return a, a_length
133
+ return None, None
134
+
135
+ assert (
136
+ a is not None
137
+ and a_length is not None
138
+ and b is not None
139
+ and b_length is not None
140
+ )
141
+ start = max(a, b)
142
+ end = min(a + a_length, b + b_length)
143
+ length = end - start
144
+
145
+ if length <= 0:
146
+ return None, None
147
+
148
+ return start, length
149
+
150
+
151
+ def roi_intersection(ref_roi: GenericRoi, other_roi: GenericRoi) -> GenericRoi | None:
152
+ """Calculate the intersection of two ROIs."""
153
+ if (
154
+ ref_roi.unit is not None
155
+ and other_roi.unit is not None
156
+ and ref_roi.unit != other_roi.unit
157
+ ):
158
+ raise NgioValueError(
159
+ "Cannot calculate intersection of ROIs with different units."
245
160
  )
246
- if out_slices is None:
247
- return None
248
161
 
249
- name = _join_roi_names(self.name, other.name)
250
- label = _join_roi_labels(self.label, other.label)
251
- return self.model_copy(
252
- update={"name": name, "slices": out_slices, "label": label}
253
- )
162
+ x, x_length = _1d_intersection(
163
+ ref_roi.x, ref_roi.x_length, other_roi.x, other_roi.x_length
164
+ )
165
+ if x is None and x_length is None:
166
+ # No intersection
167
+ return None
168
+ assert x is not None and x_length is not None
169
+
170
+ y, y_length = _1d_intersection(
171
+ ref_roi.y, ref_roi.y_length, other_roi.y, other_roi.y_length
172
+ )
173
+ if y is None and y_length is None:
174
+ # No intersection
175
+ return None
176
+ assert y is not None and y_length is not None
177
+
178
+ z, z_length = _1d_intersection(
179
+ ref_roi.z, ref_roi.z_length, other_roi.z, other_roi.z_length
180
+ )
181
+ t, t_length = _1d_intersection(
182
+ ref_roi.t, ref_roi.t_length, other_roi.t, other_roi.t_length
183
+ )
184
+
185
+ if (z_length is not None and z_length <= 0) or (
186
+ t_length is not None and t_length <= 0
187
+ ):
188
+ # No intersection
189
+ return None
254
190
 
255
- def union(self, other: Self) -> Self:
256
- if self.space != other.space:
191
+ # Find label
192
+ if ref_roi.label is not None and other_roi.label is not None:
193
+ if ref_roi.label != other_roi.label:
257
194
  raise NgioValueError(
258
- "Roi union failed: One ROI is in pixel space and the "
259
- "other in world space"
195
+ "Cannot calculate intersection of ROIs with different labels."
260
196
  )
197
+ label = ref_roi.label or other_roi.label
198
+
199
+ if ref_roi.name is not None and other_roi.name is not None:
200
+ name = f"{ref_roi.name}:{other_roi.name}"
201
+ else:
202
+ name = ref_roi.name or other_roi.name
203
+
204
+ cls_ref = ref_roi.__class__
205
+ return cls_ref(
206
+ name=name,
207
+ x=x,
208
+ y=y,
209
+ z=z,
210
+ t=t,
211
+ x_length=x_length,
212
+ y_length=y_length,
213
+ z_length=z_length,
214
+ t_length=t_length,
215
+ unit=ref_roi.unit,
216
+ label=label,
217
+ )
218
+
219
+
220
+ class Roi(GenericRoi):
221
+ x: float = 0.0
222
+ y: float = 0.0
223
+ unit: SpaceUnits | str | None = DefaultSpaceUnit
224
+
225
+ def to_roi_pixels(self, pixel_size: PixelSize) -> "RoiPixels":
226
+ """Convert to raster coordinates."""
227
+ x, x_length = _to_raster(self.x, self.x_length, pixel_size.x)
228
+ y, y_length = _to_raster(self.y, self.y_length, pixel_size.y)
229
+
230
+ if self.z is None:
231
+ z, z_length = None, None
232
+ else:
233
+ assert self.z_length is not None
234
+ z, z_length = _to_raster(self.z, self.z_length, pixel_size.z)
261
235
 
262
- out_slices = self._apply_sym_ops(
263
- self.slices, other.slices, op=lambda a, b: a.union(b)
236
+ if self.t is None:
237
+ t, t_length = None, None
238
+ else:
239
+ assert self.t_length is not None
240
+ t, t_length = _to_raster(self.t, self.t_length, pixel_size.t)
241
+ extra_dict = self.model_extra if self.model_extra else {}
242
+
243
+ return RoiPixels(
244
+ name=self.name,
245
+ x=x,
246
+ y=y,
247
+ z=z,
248
+ t=t,
249
+ x_length=x_length,
250
+ y_length=y_length,
251
+ z_length=z_length,
252
+ t_length=t_length,
253
+ label=self.label,
254
+ unit=self.unit,
255
+ **extra_dict,
264
256
  )
265
- if out_slices is None:
266
- raise NgioValueError("Roi union failed: could not compute union")
267
257
 
268
- name = _join_roi_names(self.name, other.name)
269
- label = _join_roi_labels(self.label, other.label)
270
- return self.model_copy(
271
- update={"name": name, "slices": out_slices, "label": label}
258
+ def to_pixel_roi(
259
+ self, pixel_size: PixelSize, dimensions: Dimensions | None = None
260
+ ) -> "RoiPixels":
261
+ """Convert to raster coordinates."""
262
+ warn(
263
+ "to_pixel_roi is deprecated and will be removed in a future release. "
264
+ "Use to_roi_pixels instead.",
265
+ DeprecationWarning,
266
+ stacklevel=2,
272
267
  )
273
268
 
274
- def zoom(
275
- self, zoom_factor: float = 1.0, axes: tuple[str, ...] = ("x", "y")
276
- ) -> Self:
277
- new_slices = []
278
- for roi_slice in self.slices:
279
- if roi_slice.axis_name in axes:
280
- new_slices.append(roi_slice.zoom(zoom_factor=zoom_factor))
281
- else:
282
- new_slices.append(roi_slice)
283
- return self.model_copy(update={"slices": new_slices})
284
-
285
- def to_world(self, pixel_size: PixelSize | None = None) -> Self:
286
- if self.space == "world":
287
- return self.model_copy()
288
- if pixel_size is None:
289
- raise NgioValueError(
290
- "Pixel sizes must be provided to convert ROI from pixel to world"
291
- )
292
- new_slices = []
293
- for roi_slice in self.slices:
294
- pixel_size_ = pixel_size.get(roi_slice.axis_name, default=1.0)
295
- new_slices.append(roi_slice.to_world(pixel_size=pixel_size_))
296
- return self.model_copy(update={"slices": new_slices, "space": "world"})
269
+ return self.to_roi_pixels(pixel_size=pixel_size)
297
270
 
298
- def to_pixel(self, pixel_size: PixelSize | None = None) -> Self:
299
- if self.space == "pixel":
300
- return self.model_copy()
271
+ def zoom(self, zoom_factor: float = 1) -> "Roi":
272
+ """Zoom the ROI by a factor.
301
273
 
302
- if pixel_size is None:
303
- raise NgioValueError(
304
- "Pixel sizes must be provided to convert ROI from world to pixel"
305
- )
274
+ Args:
275
+ zoom_factor: The zoom factor. If the zoom factor
276
+ is less than 1 the ROI will be zoomed in.
277
+ If the zoom factor is greater than 1 the ROI will be zoomed out.
278
+ If the zoom factor is 1 the ROI will not be changed.
279
+ """
280
+ return zoom_roi(self, zoom_factor)
306
281
 
307
- new_slices = []
308
- for roi_slice in self.slices:
309
- pixel_size_ = pixel_size.get(roi_slice.axis_name, default=1.0)
310
- new_slices.append(roi_slice.to_pixel(pixel_size=pixel_size_))
311
- return self.model_copy(update={"slices": new_slices, "space": "pixel"})
282
+ def to_slicing_dict(self, pixel_size: PixelSize) -> dict[str, slice]:
283
+ """Convert to a slicing dictionary."""
284
+ roi_pixels = self.to_roi_pixels(pixel_size)
285
+ return roi_pixels.to_slicing_dict(pixel_size)
286
+
287
+
288
+ class RoiPixels(GenericRoi):
289
+ """Region of interest (ROI) in pixel coordinates."""
290
+
291
+ x: float = 0
292
+ y: float = 0
293
+ unit: SpaceUnits | str | None = None
294
+
295
+ def to_roi(self, pixel_size: PixelSize) -> "Roi":
296
+ """Convert to raster coordinates."""
297
+ x = _raster_to_world(self.x, pixel_size.x)
298
+ x_length = _raster_to_world(self.x_length, pixel_size.x)
299
+ y = _raster_to_world(self.y, pixel_size.y)
300
+ y_length = _raster_to_world(self.y_length, pixel_size.y)
301
+
302
+ if self.z is None:
303
+ z = None
304
+ else:
305
+ z = _raster_to_world(self.z, pixel_size.z)
306
+
307
+ if self.z_length is None:
308
+ z_length = None
309
+ else:
310
+ z_length = _raster_to_world(self.z_length, pixel_size.z)
311
+
312
+ if self.t is None:
313
+ t = None
314
+ else:
315
+ t = _raster_to_world(self.t, pixel_size.t)
316
+
317
+ if self.t_length is None:
318
+ t_length = None
319
+ else:
320
+ t_length = _raster_to_world(self.t_length, pixel_size.t)
321
+
322
+ extra_dict = self.model_extra if self.model_extra else {}
323
+ return Roi(
324
+ name=self.name,
325
+ x=x,
326
+ y=y,
327
+ z=z,
328
+ t=t,
329
+ x_length=x_length,
330
+ y_length=y_length,
331
+ z_length=z_length,
332
+ t_length=t_length,
333
+ label=self.label,
334
+ unit=self.unit,
335
+ **extra_dict,
336
+ )
312
337
 
313
- def to_slicing_dict(self, pixel_size: PixelSize | None = None) -> dict[str, slice]:
314
- roi = self.to_pixel(pixel_size=pixel_size)
315
- return {roi_slice.axis_name: roi_slice.to_slice() for roi_slice in roi.slices}
338
+ def to_slicing_dict(self, pixel_size: PixelSize) -> dict[str, slice]:
339
+ """Convert to a slicing dictionary."""
340
+ x_slice = _to_slice(self.x, self.x_length)
341
+ y_slice = _to_slice(self.y, self.y_length)
342
+ z_slice = _to_slice(self.z, self.z_length)
343
+ t_slice = _to_slice(self.t, self.t_length)
344
+ return {
345
+ "x": x_slice,
346
+ "y": y_slice,
347
+ "z": z_slice,
348
+ "t": t_slice,
349
+ }
350
+
351
+
352
+ def zoom_roi(roi: Roi, zoom_factor: float = 1) -> Roi:
353
+ """Zoom the ROI by a factor.
354
+
355
+ Args:
356
+ roi: The ROI to zoom.
357
+ zoom_factor: The zoom factor. If the zoom factor
358
+ is less than 1 the ROI will be zoomed in.
359
+ If the zoom factor is greater than 1 the ROI will be zoomed out.
360
+ If the zoom factor is 1 the ROI will not be changed.
361
+ """
362
+ if zoom_factor <= 0:
363
+ raise NgioValueError("Zoom factor must be greater than 0.")
364
+
365
+ # the zoom factor needs to be rescaled
366
+ # from the range [-1, inf) to [0, inf)
367
+ zoom_factor -= 1
368
+ diff_x = roi.x_length * zoom_factor
369
+ diff_y = roi.y_length * zoom_factor
370
+
371
+ new_x = max(roi.x - diff_x / 2, 0)
372
+ new_y = max(roi.y - diff_y / 2, 0)
373
+
374
+ new_roi = Roi(
375
+ name=roi.name,
376
+ x=new_x,
377
+ y=new_y,
378
+ z=roi.z,
379
+ t=roi.t,
380
+ x_length=roi.x_length + diff_x,
381
+ y_length=roi.y_length + diff_y,
382
+ z_length=roi.z_length,
383
+ t_length=roi.t_length,
384
+ label=roi.label,
385
+ unit=roi.unit,
386
+ )
387
+ return new_roi
@@ -4,7 +4,7 @@ from typing import TypeAlias
4
4
  import dask.array as da
5
5
  import numpy as np
6
6
 
7
- from ngio.common import Roi
7
+ from ngio.common import Roi, RoiPixels
8
8
  from ngio.experimental.iterators._abstract_iterator import AbstractIteratorBuilder
9
9
  from ngio.images import Image, Label
10
10
  from ngio.images._image import (
@@ -18,8 +18,8 @@ from ngio.io_pipes import (
18
18
  TransformProtocol,
19
19
  )
20
20
 
21
- NumpyPipeType: TypeAlias = tuple[np.ndarray, np.ndarray, Roi]
22
- DaskPipeType: TypeAlias = tuple[da.Array, da.Array, Roi]
21
+ NumpyPipeType: TypeAlias = tuple[np.ndarray, np.ndarray, Roi | RoiPixels]
22
+ DaskPipeType: TypeAlias = tuple[da.Array, da.Array, Roi | RoiPixels]
23
23
 
24
24
 
25
25
  class NumpyFeatureGetter(DataGetter[NumpyPipeType]):