cuvis-ai-core 0.1.2__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 (55) hide show
  1. cuvis_ai_core/__init__.py +9 -0
  2. cuvis_ai_core/data/__init__.py +0 -0
  3. cuvis_ai_core/data/coco_labels.py +300 -0
  4. cuvis_ai_core/data/datasets.py +351 -0
  5. cuvis_ai_core/data/public_datasets.py +166 -0
  6. cuvis_ai_core/deciders/__init__.py +0 -0
  7. cuvis_ai_core/deciders/base_decider.py +60 -0
  8. cuvis_ai_core/grpc/__init__.py +32 -0
  9. cuvis_ai_core/grpc/callbacks.py +148 -0
  10. cuvis_ai_core/grpc/config_service.py +154 -0
  11. cuvis_ai_core/grpc/discovery_service.py +98 -0
  12. cuvis_ai_core/grpc/health.py +64 -0
  13. cuvis_ai_core/grpc/helpers.py +777 -0
  14. cuvis_ai_core/grpc/inference_service.py +225 -0
  15. cuvis_ai_core/grpc/introspection_service.py +150 -0
  16. cuvis_ai_core/grpc/pipeline_service.py +288 -0
  17. cuvis_ai_core/grpc/plugin_service.py +385 -0
  18. cuvis_ai_core/grpc/production_server.py +246 -0
  19. cuvis_ai_core/grpc/service.py +174 -0
  20. cuvis_ai_core/grpc/session_manager.py +192 -0
  21. cuvis_ai_core/grpc/session_service.py +87 -0
  22. cuvis_ai_core/grpc/training_service.py +621 -0
  23. cuvis_ai_core/grpc/trainrun_service.py +274 -0
  24. cuvis_ai_core/grpc/v1/__init__.py +12 -0
  25. cuvis_ai_core/grpc/v1/cuvis_ai_core_pb2.py +252 -0
  26. cuvis_ai_core/grpc/v1/cuvis_ai_core_pb2.pyi +1148 -0
  27. cuvis_ai_core/grpc/v1/cuvis_ai_core_pb2_grpc.py +1274 -0
  28. cuvis_ai_core/node/__init__.py +7 -0
  29. cuvis_ai_core/node/huggingface.py +225 -0
  30. cuvis_ai_core/node/node.py +397 -0
  31. cuvis_ai_core/pipeline/__init__.py +1 -0
  32. cuvis_ai_core/pipeline/factory.py +306 -0
  33. cuvis_ai_core/pipeline/pipeline.py +1366 -0
  34. cuvis_ai_core/pipeline/validator.py +23 -0
  35. cuvis_ai_core/pipeline/visualizer.py +506 -0
  36. cuvis_ai_core/training/__init__.py +41 -0
  37. cuvis_ai_core/training/config.py +698 -0
  38. cuvis_ai_core/training/datamodule.py +100 -0
  39. cuvis_ai_core/training/optimizer_registry.py +236 -0
  40. cuvis_ai_core/training/trainers.py +647 -0
  41. cuvis_ai_core/utils/__init__.py +16 -0
  42. cuvis_ai_core/utils/config_helpers.py +229 -0
  43. cuvis_ai_core/utils/general.py +56 -0
  44. cuvis_ai_core/utils/git_and_os.py +456 -0
  45. cuvis_ai_core/utils/graph_helper.py +44 -0
  46. cuvis_ai_core/utils/node_registry.py +583 -0
  47. cuvis_ai_core/utils/plugin_config.py +184 -0
  48. cuvis_ai_core/utils/restore.py +618 -0
  49. cuvis_ai_core/utils/serializer.py +33 -0
  50. cuvis_ai_core-0.1.2.dist-info/METADATA +192 -0
  51. cuvis_ai_core-0.1.2.dist-info/RECORD +55 -0
  52. cuvis_ai_core-0.1.2.dist-info/WHEEL +5 -0
  53. cuvis_ai_core-0.1.2.dist-info/entry_points.txt +3 -0
  54. cuvis_ai_core-0.1.2.dist-info/licenses/LICENSE +190 -0
  55. cuvis_ai_core-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("cuvis_ai_core")
5
+ except PackageNotFoundError:
6
+ # Package is not installed, likely in development mode
7
+ __version__ = "dev"
8
+
9
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,300 @@
1
+ import contextlib
2
+ import json
3
+ from collections.abc import Iterator
4
+ from copy import copy
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import torch
11
+ from dataclass_wizard import JSONWizard
12
+ from pycocotools.coco import COCO
13
+ from skimage.draw import polygon2mask
14
+ from torchvision.tv_tensors import BoundingBoxes, Mask
15
+
16
+ import io
17
+
18
+ # from contextlib import contextmanager
19
+
20
+
21
+ def RLE2mask(rle: list, mask_width: int, mask_height: int) -> np.ndarray:
22
+ mask = np.zeros(mask_width * mask_height, np.uint8)
23
+ ids = 0
24
+ value = 0
25
+ for c in rle:
26
+ mask[ids : ids + c] = value
27
+ value = not value
28
+ ids += c
29
+ mask = mask.reshape((mask_height, mask_width), order="F")
30
+ return mask.astype(bool, copy=False)
31
+
32
+
33
+ class SafeWizard(JSONWizard):
34
+ """
35
+ JSONWizard subclass that safely converts dataclasses to dicts,
36
+ keeping non-serializable objects (e.g., torch Tensors, Masks)
37
+ as-is instead of falling back to string representations.
38
+ """
39
+
40
+ def to_dict_safe(self) -> dict[str, Any]:
41
+ """
42
+ Like `to_dict()`, but leaves unsupported types untouched.
43
+ """
44
+ base_dict = super().to_dict()
45
+ final_dict = {}
46
+
47
+ for key, value in vars(self).items():
48
+ if not self._is_json_serializable(value):
49
+ # keep original object (Mask, Tensor, etc.)
50
+ final_dict[key] = value
51
+ continue
52
+ val = base_dict.get(key, value)
53
+ final_dict[key] = val
54
+ return final_dict
55
+
56
+ @staticmethod
57
+ def _is_json_serializable(obj):
58
+ try:
59
+ json.dumps(obj)
60
+ return True
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ @dataclass
66
+ class Info(JSONWizard):
67
+ description: str | None = None
68
+ url: str | None = None
69
+ version: int | None = None
70
+ contributor: str | None = None
71
+ date_created: str | None = None
72
+
73
+
74
+ @dataclass
75
+ class License(JSONWizard):
76
+ id: int
77
+ name: str
78
+ url: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class Category(JSONWizard):
83
+ id: int
84
+ name: str
85
+ supercategory: str | None = None
86
+
87
+
88
+ @dataclass
89
+ class Image(JSONWizard):
90
+ id: int
91
+ file_name: str
92
+ height: int
93
+ width: int
94
+ license: int | None = None
95
+ flickr_url: str | None = None
96
+ coco_url: str | None = None
97
+ date_captured: str | None = None
98
+ wavelength: list[float] | None = field(default_factory=list)
99
+
100
+
101
+ @dataclass
102
+ class Annotation(SafeWizard):
103
+ id: int
104
+ image_id: int
105
+ category_id: int
106
+ segmentation: list | None = None
107
+ area: float | None = None
108
+ bbox: list[float] | None = None
109
+ mask: dict | None = None
110
+ iscrowd: int | None = 0
111
+ auxiliary: dict[str, Any] | None = field(default_factory=dict)
112
+
113
+ def to_dict_safe(self) -> dict[str, Any]:
114
+ """
115
+ Like `to_dict()`, but leaves unsupported types untouched.
116
+ """
117
+ base_dict = super().to_dict()
118
+ final_dict = {}
119
+
120
+ for key, value in vars(self).items():
121
+ if not self._is_json_serializable(value):
122
+ # keep original object (Mask, Tensor, etc.)
123
+ final_dict[key] = value
124
+ continue
125
+ val = base_dict.get(key, value)
126
+ final_dict[key] = val
127
+ return final_dict
128
+
129
+ @staticmethod
130
+ def _is_json_serializable(obj):
131
+ try:
132
+ json.dumps(obj)
133
+ return True
134
+ except Exception:
135
+ return False
136
+
137
+ def to_torchvision(self, size: tuple[int, int]) -> dict[str, Any]:
138
+ """Convert COCO-style bbox/segmentation/mask into torchvision tensors."""
139
+ out = copy(self)
140
+
141
+ if self.bbox is not None:
142
+ out.bbox = BoundingBoxes(
143
+ torch.tensor([self.bbox], dtype=torch.float32),
144
+ format="XYWH",
145
+ canvas_size=size,
146
+ )
147
+
148
+ if (
149
+ self.segmentation is not None
150
+ and isinstance(self.segmentation, list)
151
+ and self.segmentation != []
152
+ ):
153
+ coords = np.array(self.segmentation[0]).reshape(-1, 2)
154
+ mask_np = polygon2mask(size, coords).astype(np.uint8)
155
+ out.segmentation = Mask(torch.from_numpy(mask_np))
156
+
157
+ if self.mask is not None:
158
+ size = self.mask["size"]
159
+ mask_np = RLE2mask(self.mask["counts"], size[0], size[1])
160
+ out.mask = Mask(torch.from_numpy(mask_np))
161
+
162
+ return out.to_dict_safe()
163
+ # return out
164
+
165
+
166
+ class QueryableList:
167
+ def __init__(self, items: list[Any]) -> None:
168
+ self._items = items
169
+
170
+ def where(self, **conditions) -> list[Any]:
171
+ """
172
+ Filter items based on conditions.
173
+ :param conditions: Keyword arguments representing field=value filters.
174
+ :return: A new QueryableList with filtered items.
175
+ """
176
+ filtered_items = self._items
177
+ for key, value in conditions.items():
178
+ filtered_items = [
179
+ item for item in filtered_items if getattr(item, key) == value
180
+ ]
181
+ return list(filtered_items)
182
+
183
+ def __iter__(self) -> Iterator[Any]:
184
+ return iter(self._items)
185
+
186
+ def __len__(self) -> int:
187
+ return len(self._items)
188
+
189
+ def __getitem__(self, index: int) -> Any:
190
+ return self._items[index]
191
+
192
+
193
+ class COCOData:
194
+ def __init__(self, coco: COCO) -> None:
195
+ self._coco = coco
196
+ self._image_ids: list[int] | None = None
197
+ self._categories: list[Category] | None = None
198
+ self._category_id_to_name: dict[int, str] | None = None
199
+ self._annotations: QueryableList | None = None
200
+ self._images: list[Image] | None = None
201
+
202
+ @classmethod
203
+ def from_path(cls, path: Path | str):
204
+ with contextlib.redirect_stdout(io.StringIO()):
205
+ return cls(COCO(path))
206
+
207
+ # @classmethod
208
+ # def from_path(cls, path):
209
+ # old_print = builtins.print
210
+ # builtins.print = lambda *a, **k: None
211
+ # try:
212
+ # return cls(COCO(str(path)))
213
+ # finally:
214
+ # builtins.print = old_print
215
+
216
+ @property
217
+ def image_ids(self) -> list[int]:
218
+ if self._image_ids is None:
219
+ self._image_ids = sorted(self._coco.imgs.keys())
220
+ return self._image_ids
221
+
222
+ @property
223
+ def info(self) -> Info:
224
+ return Info.from_dict(self._coco.dataset["info"])
225
+
226
+ @property
227
+ def license(self) -> License:
228
+ return License.from_dict(self._coco.dataset["licenses"][0])
229
+
230
+ @property
231
+ def annotations(self) -> QueryableList:
232
+ if self._annotations is None:
233
+ self._annotations = QueryableList(
234
+ [Annotation.from_dict(v) for v in self._coco.anns.values()]
235
+ )
236
+ return self._annotations
237
+
238
+ @property
239
+ def categories(self) -> list[Category]:
240
+ if self._categories is None:
241
+ self._categories = [Category.from_dict(v) for v in self._coco.cats.values()]
242
+ return self._categories
243
+
244
+ @property
245
+ def category_id_to_name(self) -> dict[int, str]:
246
+ if self._category_id_to_name is None:
247
+ self._category_id_to_name = {cat.id: cat.name for cat in self.categories}
248
+ return self._category_id_to_name
249
+
250
+ @property
251
+ def images(self) -> list[Image]:
252
+ if self._images is None:
253
+ self._images = [Image.from_dict(v) for v in self._coco.imgs.values()]
254
+ return self._images
255
+
256
+ def save(self, path: str | Path) -> None:
257
+ """
258
+ Save the current COCOData object (images, annotations, categories, etc.)
259
+ back into a COCO-style JSON file.
260
+
261
+ Automatically converts dataclasses to plain dicts and ensures
262
+ compliance with standard COCO structure.
263
+ """
264
+ path = str(path)
265
+ annotations_list: list[dict[str, Any]] = []
266
+
267
+ ann: Annotation | dict[str, Any]
268
+ for ann in self.annotations:
269
+ if isinstance(ann, Annotation):
270
+ annotations_list.append(ann.to_dict_safe())
271
+ elif isinstance(ann, dict):
272
+ annotations_list.append(ann)
273
+ else:
274
+ raise TypeError(f"Unsupported annotation type: {type(ann)}")
275
+
276
+ dataset = {
277
+ "info": self.info.to_dict() if hasattr(self, "info") else {},
278
+ "licenses": self._coco.dataset.get("licenses", []),
279
+ "images": [img.to_dict() for img in self.images],
280
+ "annotations": annotations_list,
281
+ "categories": [cat.to_dict() for cat in self.categories],
282
+ }
283
+
284
+ with open(path, "w", encoding="utf-8") as f:
285
+ json.dump(dataset, f, indent=2)
286
+
287
+ print(f"COCOData saved successfully to: {path}")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ from pycocotools.coco import COCO
292
+ import contextlib
293
+ import io
294
+
295
+ ann_file = r"..\data\Lentils\Lentils_000.json"
296
+
297
+ # with contextlib.redirect_stdout(io.StringIO()):
298
+
299
+ # coco = COCO(ann_file)
300
+ coco_data = COCOData.from_path(ann_file)
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterable, Sequence
5
+ from pathlib import Path
6
+
7
+ import cuvis
8
+ import numpy as np
9
+ from loguru import logger
10
+ from skimage.draw import polygon2mask
11
+ from torch.utils.data import Dataset
12
+
13
+ from cuvis_ai_core.data.coco_labels import Annotation, COCOData, RLE2mask
14
+ from cuvis_ai_core.utils.general import _resolve_measurement_indices
15
+
16
+
17
+ import pytorch_lightning as pl
18
+ from torch.utils.data import DataLoader
19
+
20
+ # cuvis.General.init(global_loglevel="Fatal")
21
+ # cuvis.General.set_log_level("Fatal")
22
+
23
+
24
+ class SingleCu3sDataset(Dataset):
25
+ """Load cube frames from .cu3s sessions with optional COCO-derived masks."""
26
+
27
+ def __init__(
28
+ self,
29
+ cu3s_file_path: str,
30
+ annotation_json_path: str | None = None,
31
+ processing_mode: cuvis.ProcessingMode | str | None = "Raw",
32
+ measurement_indices: Sequence[int] | Iterable[int] | None = None,
33
+ normalize_to_unit: bool = False,
34
+ ) -> None:
35
+ self.cu3s_file_path = cu3s_file_path
36
+ assert os.path.exists(cu3s_file_path), (
37
+ f"Dataset path does not exist: {cu3s_file_path}"
38
+ )
39
+ assert Path(cu3s_file_path).suffix == ".cu3s", (
40
+ f"Dataset path must point to a .cu3s file: {cu3s_file_path}"
41
+ )
42
+
43
+ self.session = cuvis.SessionFile(cu3s_file_path)
44
+ self.pc = cuvis.ProcessingContext(self.session)
45
+
46
+ has_white_ref = (
47
+ self.session.get_reference(0, cuvis.ReferenceType.White) is not None
48
+ )
49
+ has_dark_ref = (
50
+ self.session.get_reference(0, cuvis.ReferenceType.Dark) is not None
51
+ )
52
+ if processing_mode is not None:
53
+ if isinstance(processing_mode, str):
54
+ processing_mode = getattr(cuvis.ProcessingMode, processing_mode, "Raw")
55
+
56
+ if processing_mode == cuvis.ProcessingMode.Reflectance:
57
+ assert has_white_ref and has_dark_ref, (
58
+ "Reflectance processing mode requires both White and Dark references "
59
+ "in the cu3s file."
60
+ )
61
+ self.pc.processing_mode = processing_mode
62
+
63
+ mesu0 = self.session.get_measurement(0)
64
+ self.num_channels = mesu0.cube.channels
65
+ self.wavelengths = np.array(mesu0.cube.wavelength).ravel()
66
+ self._total_measurements = len(self.session)
67
+ self.measurement_indices = _resolve_measurement_indices(
68
+ measurement_indices, max_index=self._total_measurements
69
+ )
70
+ # Backwards compatibility for legacy callers.
71
+ self.mes_ids = self.measurement_indices
72
+
73
+ logger.info(
74
+ f"Loaded cu3s dataset from {cu3s_file_path} with {len(self.measurement_indices)} "
75
+ f"measurements: {self.measurement_indices}"
76
+ )
77
+ self.has_labels = (
78
+ annotation_json_path is not None
79
+ and Path(annotation_json_path).exists()
80
+ or Path(cu3s_file_path).with_suffix(".json").exists()
81
+ )
82
+ if self.has_labels and annotation_json_path is None:
83
+ # Sane fallback: label file is named the same as the Session File
84
+ annotation_json_path = Path(cu3s_file_path).with_suffix(".json")
85
+ self._coco: COCOData | None = None
86
+ self.class_labels: dict[int, str] | None = None
87
+ self.normalize_to_unit = normalize_to_unit
88
+ if self.has_labels:
89
+ try:
90
+ self._coco = COCOData.from_path(annotation_json_path)
91
+ except Exception as e:
92
+ logger.warning(
93
+ f"Could not load annotation for {annotation_json_path}:", e
94
+ )
95
+ self.has_labels = False
96
+ if self._coco is not None:
97
+ logger.info(f"Category map: {self._coco.category_id_to_name}")
98
+ self.class_labels = self._coco.category_id_to_name
99
+
100
+ def __len__(self) -> int:
101
+ return len(self.measurement_indices)
102
+
103
+ @property
104
+ def wavelengths_nm(self) -> np.ndarray:
105
+ mesu = self.session.get_measurement(0) # starts the cound from 0
106
+ wavelengths = np.array(mesu.cube.wavelength, dtype=np.int32).ravel()
107
+ return wavelengths
108
+
109
+ def __getitem__(self, idx: int) -> dict[str, np.ndarray | int]:
110
+ mesu_index = self.measurement_indices[idx]
111
+ mesu = self.session.get_measurement(mesu_index) # starts the cound from 0
112
+ if "cube" not in mesu.data:
113
+ mesu = self.pc.apply(mesu)
114
+ cube_array: np.ndarray = mesu.cube.array
115
+
116
+ wavelengths = np.array(mesu.cube.wavelength, dtype=np.int32).ravel()
117
+
118
+ out: dict[str, np.ndarray | int] = {
119
+ "cube": cube_array,
120
+ "mesu_index": mesu_index,
121
+ "wavelengths": wavelengths,
122
+ }
123
+
124
+ if self.has_labels and self._coco is not None:
125
+ # Check if we have a valid COCO image_id for this frame index
126
+ if mesu_index in self._coco.image_ids:
127
+ image_id = self._coco.image_ids[self._coco.image_ids.index(mesu_index)]
128
+ anns = self._coco.annotations.where(image_id=image_id)
129
+ category_mask = create_mask(
130
+ annotations=anns,
131
+ image_height=cube_array.shape[0],
132
+ image_width=cube_array.shape[1],
133
+ )
134
+ else:
135
+ # Frame index not in available annotations
136
+ category_mask = np.zeros(cube_array.shape[:2], dtype=np.int32)
137
+ out["mask"] = category_mask
138
+
139
+ return out
140
+
141
+
142
+ class SingleCu3sDataModule(pl.LightningDataModule):
143
+ def __init__(
144
+ self,
145
+ cu3s_file_path: str | None = None,
146
+ annotation_json_path: str | None = None,
147
+ data_dir: str | None = None,
148
+ dataset_name: str | None = None,
149
+ train_ids: list[int] | None = None,
150
+ val_ids: list[int] | None = None,
151
+ test_ids: list[int] | None = None,
152
+ batch_size: int = 2,
153
+ processing_mode: str = "Reflectance",
154
+ normalize_to_unit: bool = False,
155
+ ) -> None:
156
+ """Initialize SingleCu3sDataModule.
157
+
158
+ Two modes of operation:
159
+ 1. Explicit paths: Provide both cu3s_file_path AND annotation_json_path
160
+ 2. Auto-resolve: Provide both data_dir AND dataset_name
161
+
162
+ Args:
163
+ cu3s_file_path: Direct path to .cu3s file (takes precedence)
164
+ annotation_json_path: Direct path to .json annotation file (takes precedence)
165
+ data_dir: Directory containing dataset files
166
+ dataset_name: Name of dataset (e.g., "Lentils")
167
+ train_ids: List of measurement indices for training
168
+ val_ids: List of measurement indices for validation
169
+ test_ids: List of measurement indices for testing
170
+ batch_size: Batch size for dataloaders
171
+ processing_mode: Cuvis processing mode string ("Raw", "Reflectance")
172
+ normalize_to_unit: If True, normalize cube per-channel to [0, 1].
173
+ For band selection workflows, keep False to preserve spectral ratios.
174
+
175
+ Raises:
176
+ ValueError: If neither (cu3s_file_path, annotation_json_path) nor (data_dir, dataset_name) provided
177
+ """
178
+ super().__init__()
179
+
180
+ # Priority 1: Explicit paths
181
+ if cu3s_file_path and annotation_json_path:
182
+ self.cu3s_file_path = Path(cu3s_file_path)
183
+ self.annotation_json_path = Path(annotation_json_path)
184
+ # Priority 2: Auto-resolve from data_dir + dataset_name
185
+ elif data_dir and dataset_name:
186
+ self.cu3s_file_path, self.annotation_json_path = _resolve_assets(
187
+ Path(data_dir), dataset_name
188
+ )
189
+ else:
190
+ raise ValueError(
191
+ "Must provide either (cu3s_file_path AND annotation_json_path) OR (data_dir AND dataset_name)"
192
+ )
193
+
194
+ self.batch_size = batch_size
195
+ self.train_ids = train_ids or []
196
+ self.val_ids = val_ids or []
197
+ self.test_ids = test_ids or []
198
+ self.processing_mode = processing_mode
199
+ self.normalize_to_unit = normalize_to_unit
200
+ self.train_ds: SingleCu3sDataset | None = None
201
+ self.val_ds: SingleCu3sDataset | None = None
202
+ self.test_ds: SingleCu3sDataset | None = None
203
+
204
+ def prepare_data(self) -> None:
205
+ # Only download if using auto-resolve mode with Lentils dataset
206
+ # Skip for explicit paths (gRPC clients provide their own data)
207
+ pass
208
+
209
+ def setup(self, stage: str | None = None) -> None:
210
+ if stage == "fit" or stage is None:
211
+ if self.train_ids:
212
+ self.train_ds = SingleCu3sDataset(
213
+ cu3s_file_path=str(self.cu3s_file_path),
214
+ annotation_json_path=str(self.annotation_json_path),
215
+ processing_mode=self.processing_mode,
216
+ measurement_indices=self.train_ids,
217
+ normalize_to_unit=self.normalize_to_unit,
218
+ )
219
+ else:
220
+ self.train_ds = None
221
+
222
+ if self.val_ids:
223
+ self.val_ds = SingleCu3sDataset(
224
+ cu3s_file_path=str(self.cu3s_file_path),
225
+ annotation_json_path=str(self.annotation_json_path),
226
+ processing_mode=self.processing_mode,
227
+ measurement_indices=self.val_ids,
228
+ normalize_to_unit=self.normalize_to_unit,
229
+ )
230
+ else:
231
+ self.val_ds = None
232
+
233
+ if stage == "test" or stage is None:
234
+ if not self.test_ids:
235
+ raise ValueError("test_ids must be provided to build the test dataset.")
236
+ self.test_ds = SingleCu3sDataset(
237
+ cu3s_file_path=str(self.cu3s_file_path),
238
+ annotation_json_path=str(self.annotation_json_path),
239
+ processing_mode=self.processing_mode,
240
+ measurement_indices=self.test_ids,
241
+ normalize_to_unit=self.normalize_to_unit,
242
+ )
243
+
244
+ def train_dataloader(self) -> DataLoader:
245
+ if self.train_ds is None:
246
+ raise RuntimeError(
247
+ "Train dataset is not initialized. Call setup('fit') first."
248
+ )
249
+ return DataLoader(
250
+ self.train_ds, shuffle=True, batch_size=self.batch_size, num_workers=0
251
+ )
252
+
253
+ def val_dataloader(self) -> DataLoader:
254
+ if self.val_ds is None:
255
+ raise RuntimeError("Validation dataset is not initialized.")
256
+ return DataLoader(
257
+ self.val_ds, shuffle=False, batch_size=self.batch_size, num_workers=0
258
+ )
259
+
260
+ def test_dataloader(self) -> DataLoader:
261
+ if self.test_ds is None:
262
+ raise RuntimeError(
263
+ "Test dataset is not initialized. Call setup('test') first."
264
+ )
265
+ return DataLoader(
266
+ self.test_ds, shuffle=False, batch_size=self.batch_size, num_workers=0
267
+ )
268
+
269
+
270
+ def create_mask(
271
+ annotations: Iterable[Annotation],
272
+ image_height: int,
273
+ image_width: int,
274
+ overlap_strategy: str = "overwrite",
275
+ ) -> np.ndarray:
276
+ category_mask = np.zeros((image_height, image_width), dtype=np.int32)
277
+ for ann in annotations:
278
+ segs = ann.segmentation
279
+ mask = ann.mask
280
+ cat_id = int(ann.category_id)
281
+ if not segs and not mask:
282
+ continue
283
+
284
+ if (
285
+ isinstance(segs, list)
286
+ and len(segs) > 0
287
+ and isinstance(segs[0], (list, tuple))
288
+ ):
289
+ for seg in segs:
290
+ if len(seg) < 6:
291
+ continue
292
+ xy = np.asarray(seg, dtype=np.float32).reshape(-1, 2)
293
+ # polygon2mask expects coords in (row, col) format and returns a filled boolean mask
294
+ poly_mask = polygon2mask(
295
+ (image_height, image_width), xy[:, [1, 0]]
296
+ ) # Swap x,y to row,col
297
+ if overlap_strategy == "overwrite":
298
+ category_mask[poly_mask] = cat_id
299
+ else:
300
+ write_idx = poly_mask & (category_mask == 0)
301
+ category_mask[write_idx] = cat_id
302
+ if isinstance(mask, dict) and len(mask.get("counts", lambda: [])) > 0:
303
+ mask_width, mask_height = mask.get("size")
304
+ # decode RLE mask
305
+ decoded = RLE2mask(
306
+ mask.get("counts"), mask_width=mask_width, mask_height=mask_height
307
+ )
308
+
309
+ if overlap_strategy == "overwrite":
310
+ write_mask = decoded
311
+ else:
312
+ write_mask = decoded & (category_mask == 0)
313
+ category_mask[write_mask] = cat_id
314
+
315
+ return category_mask
316
+
317
+
318
+ def _first_available(path: Path, pattern: str) -> Path:
319
+ """Return the first file matching pattern within path."""
320
+ matches = sorted(path.glob(pattern))
321
+ if not matches:
322
+ raise FileNotFoundError(
323
+ f"Could not find any files matching '{pattern}' in {path}"
324
+ )
325
+ return matches[0]
326
+
327
+
328
+ def _resolve_assets(root: Path, dataset_name: str) -> tuple[Path, Path]:
329
+ """Locate cube and label files for given dataset name.
330
+
331
+ Args:
332
+ root: Root directory containing dataset files
333
+ dataset_name: Name of the dataset (e.g., "Lentils")
334
+
335
+ Returns:
336
+ Tuple of (cu3s_file_path, annotation_json_path)
337
+ """
338
+ default_cube = root / f"{dataset_name}.cu3s"
339
+ default_label = root / f"{dataset_name}.json"
340
+
341
+ cu3s = (
342
+ default_cube
343
+ if default_cube.exists()
344
+ else _first_available(root, f"{dataset_name}*.cu3s")
345
+ )
346
+ label = (
347
+ default_label
348
+ if default_label.exists()
349
+ else _first_available(root, f"{dataset_name}*.json")
350
+ )
351
+ return cu3s, label