ngio 0.2.0a2__py3-none-any.whl → 0.5.0b4__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 (106) hide show
  1. ngio/__init__.py +40 -12
  2. ngio/common/__init__.py +16 -32
  3. ngio/common/_dimensions.py +270 -48
  4. ngio/common/_masking_roi.py +153 -0
  5. ngio/common/_pyramid.py +267 -73
  6. ngio/common/_roi.py +290 -66
  7. ngio/common/_synt_images_utils.py +101 -0
  8. ngio/common/_zoom.py +54 -22
  9. ngio/experimental/__init__.py +5 -0
  10. ngio/experimental/iterators/__init__.py +15 -0
  11. ngio/experimental/iterators/_abstract_iterator.py +390 -0
  12. ngio/experimental/iterators/_feature.py +189 -0
  13. ngio/experimental/iterators/_image_processing.py +130 -0
  14. ngio/experimental/iterators/_mappers.py +48 -0
  15. ngio/experimental/iterators/_rois_utils.py +126 -0
  16. ngio/experimental/iterators/_segmentation.py +235 -0
  17. ngio/hcs/__init__.py +17 -58
  18. ngio/hcs/_plate.py +1354 -0
  19. ngio/images/__init__.py +30 -9
  20. ngio/images/_abstract_image.py +968 -0
  21. ngio/images/_create_synt_container.py +132 -0
  22. ngio/images/_create_utils.py +423 -0
  23. ngio/images/_image.py +926 -0
  24. ngio/images/_label.py +417 -0
  25. ngio/images/_masked_image.py +531 -0
  26. ngio/images/_ome_zarr_container.py +1235 -0
  27. ngio/images/_table_ops.py +471 -0
  28. ngio/io_pipes/__init__.py +75 -0
  29. ngio/io_pipes/_io_pipes.py +361 -0
  30. ngio/io_pipes/_io_pipes_masked.py +488 -0
  31. ngio/io_pipes/_io_pipes_roi.py +146 -0
  32. ngio/io_pipes/_io_pipes_types.py +56 -0
  33. ngio/io_pipes/_match_shape.py +377 -0
  34. ngio/io_pipes/_ops_axes.py +344 -0
  35. ngio/io_pipes/_ops_slices.py +411 -0
  36. ngio/io_pipes/_ops_slices_utils.py +199 -0
  37. ngio/io_pipes/_ops_transforms.py +104 -0
  38. ngio/io_pipes/_zoom_transform.py +180 -0
  39. ngio/ome_zarr_meta/__init__.py +39 -15
  40. ngio/ome_zarr_meta/_meta_handlers.py +490 -96
  41. ngio/ome_zarr_meta/ngio_specs/__init__.py +24 -10
  42. ngio/ome_zarr_meta/ngio_specs/_axes.py +268 -234
  43. ngio/ome_zarr_meta/ngio_specs/_channels.py +125 -41
  44. ngio/ome_zarr_meta/ngio_specs/_dataset.py +42 -87
  45. ngio/ome_zarr_meta/ngio_specs/_ngio_hcs.py +536 -2
  46. ngio/ome_zarr_meta/ngio_specs/_ngio_image.py +202 -198
  47. ngio/ome_zarr_meta/ngio_specs/_pixel_size.py +72 -34
  48. ngio/ome_zarr_meta/v04/__init__.py +21 -5
  49. ngio/ome_zarr_meta/v04/_custom_models.py +18 -0
  50. ngio/ome_zarr_meta/v04/{_v04_spec_utils.py → _v04_spec.py} +151 -90
  51. ngio/ome_zarr_meta/v05/__init__.py +27 -0
  52. ngio/ome_zarr_meta/v05/_custom_models.py +18 -0
  53. ngio/ome_zarr_meta/v05/_v05_spec.py +511 -0
  54. ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/mask.png +0 -0
  55. ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/nuclei.png +0 -0
  56. ngio/resources/20200812-CardiomyocyteDifferentiation14-Cycle1_B03/raw.jpg +0 -0
  57. ngio/resources/__init__.py +55 -0
  58. ngio/resources/resource_model.py +36 -0
  59. ngio/tables/__init__.py +20 -4
  60. ngio/tables/_abstract_table.py +270 -0
  61. ngio/tables/_tables_container.py +449 -0
  62. ngio/tables/backends/__init__.py +50 -1
  63. ngio/tables/backends/_abstract_backend.py +200 -31
  64. ngio/tables/backends/_anndata.py +139 -0
  65. ngio/tables/backends/_anndata_utils.py +10 -114
  66. ngio/tables/backends/_csv.py +19 -0
  67. ngio/tables/backends/_json.py +92 -0
  68. ngio/tables/backends/_parquet.py +19 -0
  69. ngio/tables/backends/_py_arrow_backends.py +222 -0
  70. ngio/tables/backends/_table_backends.py +162 -38
  71. ngio/tables/backends/_utils.py +608 -0
  72. ngio/tables/v1/__init__.py +19 -4
  73. ngio/tables/v1/_condition_table.py +71 -0
  74. ngio/tables/v1/_feature_table.py +79 -115
  75. ngio/tables/v1/_generic_table.py +21 -90
  76. ngio/tables/v1/_roi_table.py +486 -137
  77. ngio/transforms/__init__.py +5 -0
  78. ngio/transforms/_zoom.py +19 -0
  79. ngio/utils/__init__.py +16 -14
  80. ngio/utils/_cache.py +48 -0
  81. ngio/utils/_datasets.py +121 -13
  82. ngio/utils/_fractal_fsspec_store.py +42 -0
  83. ngio/utils/_zarr_utils.py +374 -218
  84. ngio-0.5.0b4.dist-info/METADATA +147 -0
  85. ngio-0.5.0b4.dist-info/RECORD +88 -0
  86. {ngio-0.2.0a2.dist-info → ngio-0.5.0b4.dist-info}/WHEEL +1 -1
  87. ngio/common/_array_pipe.py +0 -160
  88. ngio/common/_axes_transforms.py +0 -63
  89. ngio/common/_common_types.py +0 -5
  90. ngio/common/_slicer.py +0 -97
  91. ngio/images/abstract_image.py +0 -240
  92. ngio/images/create.py +0 -251
  93. ngio/images/image.py +0 -389
  94. ngio/images/label.py +0 -236
  95. ngio/images/omezarr_container.py +0 -535
  96. ngio/ome_zarr_meta/_generic_handlers.py +0 -320
  97. ngio/ome_zarr_meta/v04/_meta_handlers.py +0 -54
  98. ngio/tables/_validators.py +0 -192
  99. ngio/tables/backends/_anndata_v1.py +0 -75
  100. ngio/tables/backends/_json_v1.py +0 -56
  101. ngio/tables/tables_container.py +0 -300
  102. ngio/tables/v1/_masking_roi_table.py +0 -175
  103. ngio/utils/_logger.py +0 -29
  104. ngio-0.2.0a2.dist-info/METADATA +0 -95
  105. ngio-0.2.0a2.dist-info/RECORD +0 -53
  106. {ngio-0.2.0a2.dist-info → ngio-0.5.0b4.dist-info}/licenses/LICENSE +0 -0
@@ -4,16 +4,30 @@ This class follows the roi_table specification at:
4
4
  https://fractal-analytics-platform.github.io/fractal-tasks-core/tables/
5
5
  """
6
6
 
7
+ import warnings
7
8
  from collections.abc import Iterable
8
9
  from typing import Literal
10
+ from uuid import uuid4
9
11
 
10
12
  import pandas as pd
11
13
  from pydantic import BaseModel
12
14
 
13
- from ngio.common import WorldCooROI
14
- from ngio.tables._validators import validate_columns
15
- from ngio.tables.backends import ImplementedTableBackends
16
- from ngio.utils import NgioValueError, ZarrGroupHandler
15
+ from ngio.common import Roi
16
+ from ngio.tables._abstract_table import (
17
+ AbstractBaseTable,
18
+ TabularData,
19
+ )
20
+ from ngio.tables.backends import (
21
+ BackendMeta,
22
+ TableBackend,
23
+ convert_to_pandas,
24
+ normalize_pandas_df,
25
+ )
26
+ from ngio.utils import (
27
+ NgioTableValidationError,
28
+ NgioValueError,
29
+ ZarrGroupHandler,
30
+ )
17
31
 
18
32
  REQUIRED_COLUMNS = [
19
33
  "x_micrometer",
@@ -24,100 +38,274 @@ REQUIRED_COLUMNS = [
24
38
  "len_z_micrometer",
25
39
  ]
26
40
 
41
+ #####################
42
+ # Optional columns are not validated at the moment
43
+ # only a warning is raised if non optional columns are present
44
+ #####################
45
+
46
+ TIME_COLUMNS = [
47
+ "t_second",
48
+ "len_t_second",
49
+ ]
27
50
 
28
51
  ORIGIN_COLUMNS = [
29
52
  "x_micrometer_original",
30
53
  "y_micrometer_original",
54
+ "z_micrometer_original",
31
55
  ]
32
56
 
33
57
  TRANSLATION_COLUMNS = ["translation_x", "translation_y", "translation_z"]
34
58
 
35
- OPTIONAL_COLUMNS = ORIGIN_COLUMNS + TRANSLATION_COLUMNS
59
+ PLATE_COLUMNS = [
60
+ "plate_name",
61
+ "row",
62
+ "column",
63
+ "path_in_well",
64
+ "path_in_plate",
65
+ "acquisition_id",
66
+ "acquisition_name",
67
+ ]
68
+
69
+ INDEX_COLUMNS = [
70
+ "FieldIndex",
71
+ "label",
72
+ ]
73
+
74
+ OPTIONAL_COLUMNS = ORIGIN_COLUMNS + TRANSLATION_COLUMNS + PLATE_COLUMNS + INDEX_COLUMNS
75
+
76
+
77
+ def _check_optional_columns(col_name: str) -> None:
78
+ """Check if the column name is in the optional columns."""
79
+ if col_name not in OPTIONAL_COLUMNS + TIME_COLUMNS:
80
+ warnings.warn(
81
+ f"Column {col_name} is not in the optional columns.", stacklevel=2
82
+ )
36
83
 
37
84
 
38
- def _dataframe_to_rois(dataframe: pd.DataFrame) -> dict[str, WorldCooROI]:
85
+ def _dataframe_to_rois(
86
+ dataframe: pd.DataFrame,
87
+ required_columns: list[str] = REQUIRED_COLUMNS,
88
+ ) -> dict[str, Roi]:
39
89
  """Convert a DataFrame to a WorldCooROI object."""
90
+ # Validate the columns of the DataFrame
91
+ _missing_columns = set(required_columns).difference(set(dataframe.columns))
92
+ if len(_missing_columns) != 0:
93
+ raise NgioTableValidationError(
94
+ f"Could not find required columns: {_missing_columns} in the table."
95
+ )
96
+
97
+ extra_columns = set(dataframe.columns).difference(
98
+ set(required_columns + TIME_COLUMNS)
99
+ )
100
+
101
+ for col in extra_columns:
102
+ _check_optional_columns(col)
103
+
104
+ label_is_index = True if dataframe.index.name == "label" else False
105
+
106
+ extras = {}
107
+
40
108
  rois = {}
41
- for key, row in dataframe.iterrows():
109
+ for row in dataframe.itertuples(index=True):
42
110
  # check if optional columns are present
43
- origin = {col: row.get(col, None) for col in ORIGIN_COLUMNS}
44
- origin = dict(filter(lambda x: x[1] is not None, origin.items()))
45
- translation = {col: row.get(col, None) for col in TRANSLATION_COLUMNS}
46
- translation = dict(filter(lambda x: x[1] is not None, translation.items()))
47
-
48
- roi = WorldCooROI(
49
- name=str(key),
50
- x=row["x_micrometer"],
51
- y=row["y_micrometer"],
52
- z=row["z_micrometer"],
53
- x_length=row["len_x_micrometer"],
54
- y_length=row["len_y_micrometer"],
55
- z_length=row["len_z_micrometer"],
56
- unit="micrometer", # type: ignore
57
- **origin,
58
- **translation,
111
+ if len(extra_columns) > 0:
112
+ extras = {col: getattr(row, col, None) for col in extra_columns}
113
+
114
+ z_micrometer = getattr(row, "z_micrometer", None)
115
+ z_length_micrometer = getattr(row, "len_z_micrometer", None)
116
+
117
+ t_second = getattr(row, "t_second", None)
118
+ t_length_second = getattr(row, "len_t_second", None)
119
+
120
+ if label_is_index:
121
+ label = int(row.Index) # type: ignore (type can not be known here, but should be castable to int)
122
+ else:
123
+ label = getattr(row, "label", None)
124
+
125
+ slices = {
126
+ "x": (row.x_micrometer, row.len_x_micrometer),
127
+ "y": (row.y_micrometer, row.len_y_micrometer),
128
+ "z": (z_micrometer, z_length_micrometer),
129
+ }
130
+ if t_second is not None or t_length_second is not None:
131
+ slices["t"] = (t_second, t_length_second)
132
+ roi = Roi.from_values(
133
+ name=str(row.Index),
134
+ slices=slices,
135
+ space="world",
136
+ label=label,
137
+ **extras,
59
138
  )
60
139
  rois[roi.name] = roi
61
140
  return rois
62
141
 
63
142
 
64
- def _rois_to_dataframe(rois: dict[str, WorldCooROI], index_key: str) -> pd.DataFrame:
143
+ def _rois_to_dataframe(rois: dict[str, Roi], index_key: str | None) -> pd.DataFrame:
65
144
  """Convert a list of WorldCooROI objects to a DataFrame."""
66
145
  data = []
67
146
  for roi in rois.values():
147
+ # This normalization is necessary for backward compatibility
148
+ if roi.space != "world":
149
+ raise NotImplementedError(
150
+ "Only ROIs in world coordinates can be serialized."
151
+ )
152
+
153
+ z_slice = roi.get("z")
154
+ if z_slice is None:
155
+ z_micrometer = 0.0
156
+ len_z_micrometer = 1.0
157
+ else:
158
+ z_micrometer = z_slice.start if z_slice.start is not None else 0.0
159
+ len_z_micrometer = z_slice.length if z_slice.length is not None else 1.0
160
+
161
+ x_slice = roi.get("x")
162
+ if x_slice is None:
163
+ raise NgioValueError("ROI is missing 'x' slice.")
164
+ y_slice = roi.get("y")
165
+ if y_slice is None:
166
+ raise NgioValueError("ROI is missing 'y' slice.")
68
167
  row = {
69
- index_key: roi.name,
70
- "x_micrometer": roi.x,
71
- "y_micrometer": roi.y,
72
- "z_micrometer": roi.z,
73
- "len_x_micrometer": roi.x_length,
74
- "len_y_micrometer": roi.y_length,
75
- "len_z_micrometer": roi.z_length,
168
+ index_key: roi.get_name(),
169
+ "x_micrometer": x_slice.start if x_slice.start is not None else 0.0,
170
+ "y_micrometer": y_slice.start if y_slice.start is not None else 0.0,
171
+ "z_micrometer": z_micrometer,
172
+ "len_x_micrometer": x_slice.length if x_slice.length is not None else 1.0,
173
+ "len_y_micrometer": y_slice.length if y_slice.length is not None else 1.0,
174
+ "len_z_micrometer": len_z_micrometer,
76
175
  }
77
176
 
78
- extra = roi.model_extra or {}
79
- for col in ORIGIN_COLUMNS:
80
- if col in extra:
81
- row[col] = extra[col]
177
+ t_slice = roi.get("t")
178
+ if t_slice is not None:
179
+ row["t_second"] = t_slice.start if t_slice.start is not None else 0.0
180
+ row["len_t_second"] = t_slice.length if t_slice.length is not None else 1.0
82
181
 
83
- for col in TRANSLATION_COLUMNS:
84
- if col in extra:
85
- row[col] = extra[col]
182
+ if roi.label is not None and index_key != "label":
183
+ row["label"] = roi.label
184
+
185
+ extra = roi.model_extra or {}
186
+ for col in extra:
187
+ _check_optional_columns(col)
188
+ row[col] = extra[col]
86
189
  data.append(row)
190
+
87
191
  dataframe = pd.DataFrame(data)
88
- dataframe = dataframe.set_index(index_key)
192
+ dataframe = normalize_pandas_df(dataframe, index_key=index_key)
89
193
  return dataframe
90
194
 
91
195
 
92
- class ROITableV1Meta(BaseModel):
93
- """Metadata for the ROI table."""
196
+ class RoiDictWrapper:
197
+ """A wrapper for a dictionary of ROIs to provide a consistent interface."""
94
198
 
95
- fractal_table_version: Literal["1"] = "1"
96
- type: Literal["roi_table"] = "roi_table"
97
- backend: str | None = None
199
+ def __init__(self, rois: Iterable[Roi]) -> None:
200
+ self._rois_by_name = {}
201
+ self._rois_by_label = {}
202
+ for roi in rois:
203
+ name = roi.name
204
+ if name in self._rois_by_name:
205
+ name = f"{name}_{uuid4().hex[:8]}"
206
+ self._rois_by_name[name] = roi
207
+ if roi.label is not None:
208
+ self._rois_by_label[roi.label] = roi
98
209
 
210
+ def get_by_name(self, name: str, default: Roi | None = None) -> Roi | None:
211
+ """Get an ROI by its name."""
212
+ return self._rois_by_name.get(name, default)
99
213
 
100
- class RoiTableV1:
101
- """Class to handle fractal ROI tables.
214
+ def get_by_label(self, label: int, default: Roi | None = None) -> Roi | None:
215
+ """Get an ROI by its label."""
216
+ return self._rois_by_label.get(label, default)
102
217
 
103
- To know more about the ROI table format, please refer to the
104
- specification at:
105
- https://fractal-analytics-platform.github.io/fractal-tasks-core/tables/
106
- """
218
+ def _add_roi(self, roi: Roi, overwrite: bool = False) -> None:
219
+ """Add an ROI to the wrapper."""
220
+ if roi.name in self._rois_by_name and not overwrite:
221
+ raise NgioValueError(f"ROI with name {roi.name} already exists.")
107
222
 
108
- def __init__(self, rois: Iterable[WorldCooROI] | None = None) -> None:
109
- """Create a new ROI table."""
110
- self._meta = ROITableV1Meta()
111
- self._table_backend = None
223
+ self._rois_by_name[roi.name] = roi
224
+ if roi.label is not None:
225
+ self._rois_by_label[roi.label] = roi
226
+
227
+ def add_rois(self, rois: Roi | Iterable[Roi], overwrite: bool = False) -> None:
228
+ """Add ROIs to the wrapper."""
229
+ if isinstance(rois, Roi):
230
+ rois = [rois]
231
+
232
+ for roi in rois:
233
+ self._add_roi(roi, overwrite=overwrite)
234
+
235
+ def to_list(self) -> list[Roi]:
236
+ """Return the list of ROIs."""
237
+ return list(self._rois_by_name.values())
238
+
239
+ def to_dataframe(self, index_key: str | None = None) -> pd.DataFrame:
240
+ """Convert the ROIs to a DataFrame."""
241
+ return _rois_to_dataframe(self._rois_by_name, index_key=index_key)
112
242
 
113
- self._rois = {}
243
+ @classmethod
244
+ def from_dataframe(
245
+ cls, dataframe: pd.DataFrame, required_columns: list[str] = REQUIRED_COLUMNS
246
+ ) -> "RoiDictWrapper":
247
+ """Create a RoiDictWrapper from a DataFrame."""
248
+ rois = _dataframe_to_rois(dataframe, required_columns=required_columns)
249
+ return cls(rois.values())
250
+
251
+
252
+ def _table_to_rois(
253
+ table: TabularData,
254
+ index_key: str | None = None,
255
+ index_type: Literal["int", "str"] | None = None,
256
+ required_columns: list[str] = REQUIRED_COLUMNS,
257
+ ) -> tuple[pd.DataFrame, RoiDictWrapper]:
258
+ """Convert a table to a dictionary of ROIs.
259
+
260
+ Args:
261
+ table: The table to convert.
262
+ index_key: The column name to use as the index of the DataFrame.
263
+ index_type: The type of the index column in the DataFrame.
264
+ required_columns: The required columns in the DataFrame.
265
+
266
+ Returns:
267
+ A tuple containing the DataFrame and a RoiDictWrapper with the ROIs.
268
+ """
269
+ dataframe = convert_to_pandas(
270
+ table,
271
+ index_key=index_key,
272
+ index_type=index_type,
273
+ )
274
+ roi_dict_wrapper = RoiDictWrapper.from_dataframe(
275
+ dataframe, required_columns=required_columns
276
+ )
277
+ return dataframe, roi_dict_wrapper
278
+
279
+
280
+ class GenericRoiTableV1(AbstractBaseTable):
281
+ def __init__(
282
+ self,
283
+ *,
284
+ rois: Iterable[Roi] | None = None,
285
+ meta: BackendMeta,
286
+ required_columns: list[str] = REQUIRED_COLUMNS,
287
+ ) -> None:
288
+ table = None
289
+
290
+ self._rois: RoiDictWrapper | None = None
114
291
  if rois is not None:
115
- self.add(rois)
292
+ self._rois = RoiDictWrapper(rois)
293
+ table = self._rois.to_dataframe(index_key=meta.index_key)
294
+
295
+ self._required_columns = required_columns
296
+ super().__init__(table_data=table, meta=meta)
297
+
298
+ def __repr__(self) -> str:
299
+ """Return a string representation of the table."""
300
+ rois = self.rois()
301
+ prop = f"num_rois={len(rois)}"
302
+ class_name = self.__class__.__name__
303
+ return f"{class_name}({prop})"
116
304
 
117
305
  @staticmethod
118
- def type() -> Literal["roi_table"]:
306
+ def table_type() -> str:
119
307
  """Return the type of the table."""
120
- return "roi_table"
308
+ return "generic_roi_table"
121
309
 
122
310
  @staticmethod
123
311
  def version() -> Literal["1"]:
@@ -125,102 +313,263 @@ class RoiTableV1:
125
313
  return "1"
126
314
 
127
315
  @property
128
- def backend_name(self) -> str | None:
129
- """Return the name of the backend."""
130
- if self._table_backend is None:
131
- return None
132
- return self._table_backend.backend_name()
316
+ def table_data(self) -> TabularData:
317
+ """Return the table."""
318
+ if self._rois is None:
319
+ return super().table_data
133
320
 
134
- @classmethod
135
- def _from_handler(
136
- cls, handler: ZarrGroupHandler, backend_name: str | None = None
137
- ) -> "RoiTableV1":
138
- """Create a new ROI table from a Zarr store."""
139
- meta = ROITableV1Meta(**handler.load_attrs())
140
-
141
- if backend_name is None:
142
- backend = ImplementedTableBackends().get_backend(
143
- backend_name=meta.backend,
144
- group_handler=handler,
145
- index_key="FieldIndex",
146
- index_type="str",
147
- )
148
- else:
149
- backend = ImplementedTableBackends().get_backend(
150
- backend_name=backend_name,
151
- group_handler=handler,
152
- index_key="FieldIndex",
153
- index_type="str",
321
+ if len(self.rois()) > 0:
322
+ self._table_data = self._rois.to_dataframe(index_key=self.meta.index_key)
323
+ return super().table_data
324
+
325
+ def set_table_data(
326
+ self, table_data: TabularData | None = None, refresh: bool = False
327
+ ) -> None:
328
+ if table_data is not None:
329
+ if not isinstance(table_data, TabularData):
330
+ raise NgioValueError(
331
+ "The table must be a pandas DataFrame, polars LazyFrame, "
332
+ " or AnnData object."
333
+ )
334
+
335
+ table_data, rois = _table_to_rois(
336
+ table_data,
337
+ index_key=self.index_key,
338
+ index_type=self.index_type,
339
+ required_columns=REQUIRED_COLUMNS,
154
340
  )
155
- meta.backend = backend_name
341
+ self._table_data = table_data
342
+ self._rois = rois
343
+ return None
156
344
 
157
- if not backend.implements_dataframe:
345
+ if self._table_data is not None and not refresh:
346
+ return None
347
+
348
+ if self._table_backend is None:
158
349
  raise NgioValueError(
159
- "The backend does not implement the dataframe protocol."
350
+ "The table does not have a DataFrame in memory nor a backend."
160
351
  )
161
352
 
162
- table = cls()
163
- table._meta = meta
164
- table._table_backend = backend
353
+ table_data, rois = _table_to_rois(
354
+ self._table_backend.load(),
355
+ index_key=self.index_key,
356
+ index_type=self.index_type,
357
+ required_columns=REQUIRED_COLUMNS,
358
+ )
359
+ self._table_data = table_data
360
+ self._rois = rois
361
+
362
+ def _check_rois(self) -> None:
363
+ """Load the ROIs from the table.
364
+
365
+ If the ROIs are already loaded, do nothing.
366
+ If the ROIs are not loaded, load them from the table.
367
+ """
368
+ if self._rois is None:
369
+ self._rois = RoiDictWrapper.from_dataframe(
370
+ self.dataframe, required_columns=self._required_columns
371
+ )
372
+
373
+ def rois(self) -> list[Roi]:
374
+ """List all ROIs in the table."""
375
+ self._check_rois()
376
+ if self._rois is None:
377
+ return []
378
+ return self._rois.to_list()
379
+
380
+ def add(self, roi: Roi | Iterable[Roi], overwrite: bool = False) -> None:
381
+ """Append ROIs to the current table.
382
+
383
+ Args:
384
+ roi: A single ROI or a list of ROIs to add to the table.
385
+ overwrite: If True, overwrite existing ROIs with the same name.
386
+ """
387
+ if isinstance(roi, Roi):
388
+ roi = [roi]
389
+
390
+ self._check_rois()
391
+ if self._rois is None:
392
+ self._rois = RoiDictWrapper([])
165
393
 
166
- dataframe = backend.load_as_dataframe()
167
- dataframe = validate_columns(
168
- dataframe,
394
+ self._rois.add_rois(roi, overwrite=overwrite)
395
+
396
+ def get(self, roi_name: str) -> Roi:
397
+ """Get an ROI from the table."""
398
+ self._check_rois()
399
+ if self._rois is None:
400
+ self._rois = RoiDictWrapper([])
401
+
402
+ roi = self._rois.get_by_name(roi_name)
403
+ if roi is None:
404
+ raise NgioValueError(f"ROI with name {roi_name} not found in the table.")
405
+ return roi
406
+
407
+ @classmethod
408
+ def from_table_data(
409
+ cls, table_data: TabularData, meta: BackendMeta
410
+ ) -> "GenericRoiTableV1":
411
+ """Create a new ROI table from a table data."""
412
+ _, rois = _table_to_rois(
413
+ table=table_data,
414
+ index_key=meta.index_key,
415
+ index_type=meta.index_type,
169
416
  required_columns=REQUIRED_COLUMNS,
170
- optional_columns=OPTIONAL_COLUMNS,
171
417
  )
172
- table._rois = _dataframe_to_rois(dataframe)
173
- return table
418
+ return cls(rois=rois.to_list(), meta=meta)
174
419
 
175
- def _set_backend(
176
- self,
177
- handler: ZarrGroupHandler,
178
- backend_name: str | None = None,
420
+
421
+ class RoiTableV1Meta(BackendMeta):
422
+ """Metadata for the ROI table."""
423
+
424
+ table_version: Literal["1"] = "1"
425
+ type: Literal["roi_table"] = "roi_table"
426
+ index_key: str | None = "FieldIndex"
427
+ index_type: Literal["str", "int"] | None = "str"
428
+
429
+
430
+ class RoiTableV1(GenericRoiTableV1):
431
+ """Class to handle fractal ROI tables.
432
+
433
+ To know more about the ROI table format, please refer to the
434
+ specification at:
435
+ https://fractal-analytics-platform.github.io/fractal-tasks-core/tables/
436
+ """
437
+
438
+ def __init__(
439
+ self, rois: Iterable[Roi] | None = None, *, meta: RoiTableV1Meta | None = None
179
440
  ) -> None:
180
- """Set the backend of the table."""
181
- backend = ImplementedTableBackends().get_backend(
182
- backend_name=backend_name,
183
- group_handler=handler,
184
- index_key="FieldIndex",
185
- index_type="str",
441
+ """Create a new ROI table."""
442
+ if meta is None:
443
+ meta = RoiTableV1Meta()
444
+
445
+ if meta.index_key is None:
446
+ meta.index_key = "FieldIndex"
447
+
448
+ if meta.index_type is None:
449
+ meta.index_type = "str"
450
+ super().__init__(meta=meta, rois=rois)
451
+
452
+ @classmethod
453
+ def from_handler(
454
+ cls,
455
+ handler: ZarrGroupHandler,
456
+ backend: TableBackend | None = None,
457
+ ) -> "RoiTableV1":
458
+ table = cls._from_handler(
459
+ handler=handler,
460
+ backend=backend,
461
+ meta_model=RoiTableV1Meta,
186
462
  )
187
- self._meta.backend = backend_name
188
- self._table_backend = backend
463
+ return table
189
464
 
190
- def rois(self) -> list[WorldCooROI]:
191
- """List all ROIs in the table."""
192
- return list(self._rois.values())
465
+ @staticmethod
466
+ def table_type() -> Literal["roi_table"]:
467
+ """Return the type of the table."""
468
+ return "roi_table"
193
469
 
194
- def get(self, roi_name: str) -> WorldCooROI:
195
- """Get an ROI from the table."""
196
- if roi_name not in self._rois:
197
- raise NgioValueError(f"ROI {roi_name} not found in the table.")
198
- return self._rois[roi_name]
199
470
 
200
- def add(self, roi: WorldCooROI | Iterable[WorldCooROI]) -> None:
201
- """Append ROIs to the current table."""
202
- if isinstance(roi, WorldCooROI):
203
- roi = [roi]
471
+ class RegionMeta(BaseModel):
472
+ """Metadata for the region."""
204
473
 
205
- for _roi in roi:
206
- if _roi.name in self._rois:
207
- raise NgioValueError(f"ROI {_roi.name} already exists in the table.")
208
- self._rois[_roi.name] = _roi
474
+ path: str
209
475
 
210
- def consolidate(self) -> None:
211
- """Write the current state of the table to the Zarr file."""
212
- if self._table_backend is None:
476
+
477
+ class MaskingRoiTableV1Meta(BackendMeta):
478
+ """Metadata for the ROI table."""
479
+
480
+ table_version: Literal["1"] = "1"
481
+ type: Literal["masking_roi_table"] = "masking_roi_table"
482
+ region: RegionMeta | None = None
483
+ instance_key: str = "label"
484
+ index_key: str | None = "label"
485
+ index_type: Literal["int", "str"] | None = "int"
486
+
487
+
488
+ class MaskingRoiTableV1(GenericRoiTableV1):
489
+ """Class to handle fractal ROI tables.
490
+
491
+ To know more about the ROI table format, please refer to the
492
+ specification at:
493
+ https://fractal-analytics-platform.github.io/fractal-tasks-core/tables/
494
+ """
495
+
496
+ def __init__(
497
+ self,
498
+ rois: Iterable[Roi] | None = None,
499
+ *,
500
+ reference_label: str | None = None,
501
+ meta: MaskingRoiTableV1Meta | None = None,
502
+ ) -> None:
503
+ """Create a new ROI table."""
504
+ if meta is None:
505
+ meta = MaskingRoiTableV1Meta()
506
+
507
+ if reference_label is not None:
508
+ path = f"../labels/{reference_label}"
509
+ meta.region = RegionMeta(path=path)
510
+
511
+ if meta.index_key is None:
512
+ meta.index_key = "label"
513
+
514
+ if meta.index_type is None:
515
+ meta.index_type = "int"
516
+ meta.instance_key = meta.index_key
517
+ super().__init__(meta=meta, rois=rois)
518
+
519
+ def __repr__(self) -> str:
520
+ """Return a string representation of the table."""
521
+ rois = self.rois()
522
+ if self.reference_label is not None:
523
+ prop = f"num_rois={len(rois)}, reference_label={self.reference_label}"
524
+ else:
525
+ prop = f"num_rois={len(rois)}"
526
+ return f"MaskingRoiTableV1({prop})"
527
+
528
+ @classmethod
529
+ def from_handler(
530
+ cls,
531
+ handler: ZarrGroupHandler,
532
+ backend: TableBackend | None = None,
533
+ ) -> "MaskingRoiTableV1":
534
+ table = cls._from_handler(
535
+ handler=handler,
536
+ backend=backend,
537
+ meta_model=MaskingRoiTableV1Meta,
538
+ )
539
+ return table
540
+
541
+ @staticmethod
542
+ def table_type() -> Literal["masking_roi_table"]:
543
+ """Return the type of the table."""
544
+ return "masking_roi_table"
545
+
546
+ @property
547
+ def meta(self) -> MaskingRoiTableV1Meta:
548
+ """Return the metadata of the table."""
549
+ if not isinstance(self._meta, MaskingRoiTableV1Meta):
213
550
  raise NgioValueError(
214
- "No backend set for the table. "
215
- "Please add the table to a OME-Zarr Image before calling consolidate."
551
+ "The metadata of the table is not of type MaskingRoiTableV1Meta."
216
552
  )
553
+ return self._meta
217
554
 
218
- dataframe = _rois_to_dataframe(self._rois, index_key="FieldIndex")
219
- dataframe = validate_columns(
220
- dataframe,
221
- required_columns=REQUIRED_COLUMNS,
222
- optional_columns=OPTIONAL_COLUMNS,
223
- )
224
- self._table_backend.write_from_dataframe(
225
- dataframe, metadata=self._meta.model_dump(exclude_none=True)
226
- )
555
+ @property
556
+ def reference_label(self) -> str | None:
557
+ """Return the reference label."""
558
+ path = self.meta.region
559
+ if path is None:
560
+ return None
561
+
562
+ path = path.path
563
+ path = path.split("/")[-1]
564
+ return path
565
+
566
+ def get_label(self, label: int) -> Roi:
567
+ """Get an ROI by label."""
568
+ self._check_rois()
569
+ if self._rois is None:
570
+ self._rois = RoiDictWrapper([])
571
+ roi = self._rois.get_by_label(label)
572
+
573
+ if roi is None:
574
+ raise NgioValueError(f"ROI with label {label} not found.")
575
+ return roi