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.
- cuvis_ai_core/__init__.py +9 -0
- cuvis_ai_core/data/__init__.py +0 -0
- cuvis_ai_core/data/coco_labels.py +300 -0
- cuvis_ai_core/data/datasets.py +351 -0
- cuvis_ai_core/data/public_datasets.py +166 -0
- cuvis_ai_core/deciders/__init__.py +0 -0
- cuvis_ai_core/deciders/base_decider.py +60 -0
- cuvis_ai_core/grpc/__init__.py +32 -0
- cuvis_ai_core/grpc/callbacks.py +148 -0
- cuvis_ai_core/grpc/config_service.py +154 -0
- cuvis_ai_core/grpc/discovery_service.py +98 -0
- cuvis_ai_core/grpc/health.py +64 -0
- cuvis_ai_core/grpc/helpers.py +777 -0
- cuvis_ai_core/grpc/inference_service.py +225 -0
- cuvis_ai_core/grpc/introspection_service.py +150 -0
- cuvis_ai_core/grpc/pipeline_service.py +288 -0
- cuvis_ai_core/grpc/plugin_service.py +385 -0
- cuvis_ai_core/grpc/production_server.py +246 -0
- cuvis_ai_core/grpc/service.py +174 -0
- cuvis_ai_core/grpc/session_manager.py +192 -0
- cuvis_ai_core/grpc/session_service.py +87 -0
- cuvis_ai_core/grpc/training_service.py +621 -0
- cuvis_ai_core/grpc/trainrun_service.py +274 -0
- cuvis_ai_core/grpc/v1/__init__.py +12 -0
- cuvis_ai_core/grpc/v1/cuvis_ai_core_pb2.py +252 -0
- cuvis_ai_core/grpc/v1/cuvis_ai_core_pb2.pyi +1148 -0
- cuvis_ai_core/grpc/v1/cuvis_ai_core_pb2_grpc.py +1274 -0
- cuvis_ai_core/node/__init__.py +7 -0
- cuvis_ai_core/node/huggingface.py +225 -0
- cuvis_ai_core/node/node.py +397 -0
- cuvis_ai_core/pipeline/__init__.py +1 -0
- cuvis_ai_core/pipeline/factory.py +306 -0
- cuvis_ai_core/pipeline/pipeline.py +1366 -0
- cuvis_ai_core/pipeline/validator.py +23 -0
- cuvis_ai_core/pipeline/visualizer.py +506 -0
- cuvis_ai_core/training/__init__.py +41 -0
- cuvis_ai_core/training/config.py +698 -0
- cuvis_ai_core/training/datamodule.py +100 -0
- cuvis_ai_core/training/optimizer_registry.py +236 -0
- cuvis_ai_core/training/trainers.py +647 -0
- cuvis_ai_core/utils/__init__.py +16 -0
- cuvis_ai_core/utils/config_helpers.py +229 -0
- cuvis_ai_core/utils/general.py +56 -0
- cuvis_ai_core/utils/git_and_os.py +456 -0
- cuvis_ai_core/utils/graph_helper.py +44 -0
- cuvis_ai_core/utils/node_registry.py +583 -0
- cuvis_ai_core/utils/plugin_config.py +184 -0
- cuvis_ai_core/utils/restore.py +618 -0
- cuvis_ai_core/utils/serializer.py +33 -0
- cuvis_ai_core-0.1.2.dist-info/METADATA +192 -0
- cuvis_ai_core-0.1.2.dist-info/RECORD +55 -0
- cuvis_ai_core-0.1.2.dist-info/WHEEL +5 -0
- cuvis_ai_core-0.1.2.dist-info/entry_points.txt +3 -0
- cuvis_ai_core-0.1.2.dist-info/licenses/LICENSE +190 -0
- cuvis_ai_core-0.1.2.dist-info/top_level.txt +1 -0
|
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
|