supervisely 6.73.393__py3-none-any.whl → 6.73.395__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.
- supervisely/api/entity_annotation/entity_annotation_api.py +3 -1
- supervisely/api/entity_annotation/figure_api.py +25 -16
- supervisely/api/module_api.py +2 -0
- supervisely/api/volume/volume_annotation_api.py +4 -2
- supervisely/api/volume/volume_figure_api.py +36 -7
- supervisely/convert/base_converter.py +2 -2
- supervisely/convert/volume/nii/nii_planes_volume_converter.py +51 -13
- supervisely/convert/volume/nii/nii_volume_converter.py +1 -1
- supervisely/convert/volume/nii/nii_volume_helper.py +96 -36
- supervisely/convert/volume/sly/sly_volume_converter.py +32 -3
- supervisely/nn/inference/inference.py +274 -35
- supervisely/nn/training/train_app.py +19 -20
- supervisely/project/volume_project.py +6 -0
- supervisely/template/experiment/experiment.html.jinja +4 -4
- supervisely/template/experiment/experiment_generator.py +1 -1
- supervisely/volume_annotation/volume_figure.py +45 -1
- supervisely/volume_annotation/volume_object.py +23 -6
- {supervisely-6.73.393.dist-info → supervisely-6.73.395.dist-info}/METADATA +1 -1
- {supervisely-6.73.393.dist-info → supervisely-6.73.395.dist-info}/RECORD +23 -23
- {supervisely-6.73.393.dist-info → supervisely-6.73.395.dist-info}/LICENSE +0 -0
- {supervisely-6.73.393.dist-info → supervisely-6.73.395.dist-info}/WHEEL +0 -0
- {supervisely-6.73.393.dist-info → supervisely-6.73.395.dist-info}/entry_points.txt +0 -0
- {supervisely-6.73.393.dist-info → supervisely-6.73.395.dist-info}/top_level.txt +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# coding: utf-8
|
|
2
2
|
|
|
3
3
|
# docs
|
|
4
|
+
from typing import Callable, Dict, List, Optional, Union
|
|
5
|
+
|
|
4
6
|
from tqdm import tqdm
|
|
5
|
-
|
|
7
|
+
|
|
6
8
|
from supervisely._utils import batched
|
|
7
9
|
from supervisely.api.module_api import ApiField, ModuleApi
|
|
8
10
|
from supervisely.video_annotation.key_id_map import KeyIdMap
|
|
@@ -69,6 +69,7 @@ class FigureInfo(NamedTuple):
|
|
|
69
69
|
meta: dict
|
|
70
70
|
area: str
|
|
71
71
|
priority: Optional[int] = None
|
|
72
|
+
custom_data: Optional[dict] = None
|
|
72
73
|
|
|
73
74
|
@property
|
|
74
75
|
def bbox(self) -> Optional[Rectangle]:
|
|
@@ -135,6 +136,7 @@ class FigureApi(RemoveableBulkModuleApi):
|
|
|
135
136
|
ApiField.META,
|
|
136
137
|
ApiField.AREA,
|
|
137
138
|
ApiField.PRIORITY,
|
|
139
|
+
ApiField.CUSTOM_DATA,
|
|
138
140
|
]
|
|
139
141
|
|
|
140
142
|
@staticmethod
|
|
@@ -229,7 +231,8 @@ class FigureApi(RemoveableBulkModuleApi):
|
|
|
229
231
|
meta: Dict,
|
|
230
232
|
geometry_json: Dict,
|
|
231
233
|
geometry_type: str,
|
|
232
|
-
track_id: int = None,
|
|
234
|
+
track_id: Optional[int] = None,
|
|
235
|
+
custom_data: Optional[dict] = None,
|
|
233
236
|
) -> int:
|
|
234
237
|
""""""
|
|
235
238
|
input_figure = {
|
|
@@ -242,6 +245,9 @@ class FigureApi(RemoveableBulkModuleApi):
|
|
|
242
245
|
if track_id is not None:
|
|
243
246
|
input_figure[ApiField.TRACK_ID] = track_id
|
|
244
247
|
|
|
248
|
+
if custom_data is not None:
|
|
249
|
+
input_figure[ApiField.CUSTOM_DATA] = custom_data
|
|
250
|
+
|
|
245
251
|
body = {ApiField.ENTITY_ID: entity_id, ApiField.FIGURES: [input_figure]}
|
|
246
252
|
|
|
247
253
|
response = self._api.post("figures.bulk.add", body)
|
|
@@ -354,21 +360,22 @@ class FigureApi(RemoveableBulkModuleApi):
|
|
|
354
360
|
"""
|
|
355
361
|
filters = [{"field": "id", "operator": "in", "value": ids}]
|
|
356
362
|
fields = [
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
363
|
+
ApiField.ID,
|
|
364
|
+
ApiField.CREATED_AT,
|
|
365
|
+
ApiField.UPDATED_AT,
|
|
366
|
+
ApiField.IMAGE_ID,
|
|
367
|
+
ApiField.OBJECT_ID,
|
|
368
|
+
ApiField.CLASS_ID,
|
|
369
|
+
ApiField.PROJECT_ID,
|
|
370
|
+
ApiField.DATASET_ID,
|
|
371
|
+
ApiField.GEOMETRY,
|
|
372
|
+
ApiField.GEOMETRY_TYPE,
|
|
373
|
+
ApiField.GEOMETRY_META,
|
|
374
|
+
ApiField.TAGS,
|
|
375
|
+
ApiField.META,
|
|
376
|
+
ApiField.AREA,
|
|
377
|
+
ApiField.PRIORITY,
|
|
378
|
+
ApiField.CUSTOM_DATA,
|
|
372
379
|
]
|
|
373
380
|
figures_infos = self.get_list_all_pages(
|
|
374
381
|
"figures.list",
|
|
@@ -488,6 +495,7 @@ class FigureApi(RemoveableBulkModuleApi):
|
|
|
488
495
|
ApiField.META,
|
|
489
496
|
ApiField.AREA,
|
|
490
497
|
ApiField.PRIORITY,
|
|
498
|
+
ApiField.CUSTOM_DATA,
|
|
491
499
|
]
|
|
492
500
|
if skip_geometry is True:
|
|
493
501
|
fields = [x for x in fields if x != ApiField.GEOMETRY]
|
|
@@ -840,6 +848,7 @@ class FigureApi(RemoveableBulkModuleApi):
|
|
|
840
848
|
ApiField.META,
|
|
841
849
|
ApiField.AREA,
|
|
842
850
|
ApiField.PRIORITY,
|
|
851
|
+
ApiField.CUSTOM_DATA,
|
|
843
852
|
]
|
|
844
853
|
if skip_geometry is True:
|
|
845
854
|
fields = [x for x in fields if x != ApiField.GEOMETRY]
|
supervisely/api/module_api.py
CHANGED
|
@@ -312,11 +312,13 @@ class VolumeAnnotationAPI(EntityAnnotationAPI):
|
|
|
312
312
|
|
|
313
313
|
for nrrd_path in nrrd_paths:
|
|
314
314
|
object_key = None
|
|
315
|
+
custom_data = None
|
|
315
316
|
|
|
316
317
|
# searching connection between interpolation and spatial figure in annotations and set its object_key
|
|
317
318
|
for sf in ann.spatial_figures:
|
|
318
319
|
if sf.key().hex == get_file_name(nrrd_path):
|
|
319
320
|
object_key = sf.parent_object.key()
|
|
321
|
+
custom_data = sf.custom_data
|
|
320
322
|
break
|
|
321
323
|
|
|
322
324
|
if object_key:
|
|
@@ -341,12 +343,12 @@ class VolumeAnnotationAPI(EntityAnnotationAPI):
|
|
|
341
343
|
geometry = Mask3D(np.zeros((3, 3, 3), dtype=np.bool_))
|
|
342
344
|
|
|
343
345
|
if transfer_type == "download":
|
|
344
|
-
new_object = VolumeObject(new_obj_class, mask_3d=geometry)
|
|
346
|
+
new_object = VolumeObject(new_obj_class, mask_3d=geometry, custom_data=custom_data)
|
|
345
347
|
elif transfer_type == "upload":
|
|
346
348
|
if class_created:
|
|
347
349
|
self._api.project.update_meta(project_id, project_meta)
|
|
348
350
|
new_object = VolumeObject(new_obj_class)
|
|
349
|
-
new_object.figure = VolumeFigure(new_object, geometry, key=sf.key())
|
|
351
|
+
new_object.figure = VolumeFigure(new_object, geometry, key=sf.key(), custom_data=custom_data)
|
|
350
352
|
|
|
351
353
|
# add new Volume object to VolumeAnnotation with spatial figure
|
|
352
354
|
ann = ann.add_objects([new_object])
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import os
|
|
3
3
|
import re
|
|
4
4
|
import tempfile
|
|
5
|
-
from typing import Dict, List
|
|
5
|
+
from typing import Dict, List, Literal, Optional
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
8
|
from numpy import uint8
|
|
@@ -33,7 +33,8 @@ class VolumeFigureApi(FigureApi):
|
|
|
33
33
|
plane_name: str,
|
|
34
34
|
slice_index: int,
|
|
35
35
|
geometry_json: dict,
|
|
36
|
-
geometry_type,
|
|
36
|
+
geometry_type: str,
|
|
37
|
+
custom_data: Optional[dict] = None,
|
|
37
38
|
# track_id=None,
|
|
38
39
|
):
|
|
39
40
|
"""
|
|
@@ -98,6 +99,7 @@ class VolumeFigureApi(FigureApi):
|
|
|
98
99
|
},
|
|
99
100
|
geometry_json,
|
|
100
101
|
geometry_type,
|
|
102
|
+
custom_data=custom_data,
|
|
101
103
|
# track_id,
|
|
102
104
|
)
|
|
103
105
|
|
|
@@ -420,7 +422,7 @@ class VolumeFigureApi(FigureApi):
|
|
|
420
422
|
def _append_bulk_mask3d(
|
|
421
423
|
self,
|
|
422
424
|
entity_id: int,
|
|
423
|
-
figures: List,
|
|
425
|
+
figures: List[VolumeFigure],
|
|
424
426
|
figures_keys: List,
|
|
425
427
|
key_id_map: KeyIdMap,
|
|
426
428
|
field_name=ApiField.ENTITY_ID,
|
|
@@ -449,10 +451,11 @@ class VolumeFigureApi(FigureApi):
|
|
|
449
451
|
for figure in figures:
|
|
450
452
|
empty_figures.append(
|
|
451
453
|
{
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
454
|
+
ApiField.OBJECT_ID: key_id_map.get_object_id(figure.volume_object.key()),
|
|
455
|
+
ApiField.GEOMETRY_TYPE: Mask3D.name(),
|
|
456
|
+
ApiField.LABELING_TOOL: Mask3D.name(),
|
|
457
|
+
ApiField.ENTITY_ID: entity_id,
|
|
458
|
+
ApiField.CUSTOM_DATA: figure.custom_data
|
|
456
459
|
}
|
|
457
460
|
)
|
|
458
461
|
for batch_keys, batch_jsons in zip(
|
|
@@ -643,3 +646,29 @@ class VolumeFigureApi(FigureApi):
|
|
|
643
646
|
if kwargs.get("image_ids", False) is not False:
|
|
644
647
|
volume_ids = kwargs["image_ids"] # backward compatibility
|
|
645
648
|
return super().download(dataset_id, volume_ids, skip_geometry)
|
|
649
|
+
|
|
650
|
+
def update_custom_data(
|
|
651
|
+
self,
|
|
652
|
+
figure_id: int,
|
|
653
|
+
custom_data: Dict[str, str],
|
|
654
|
+
update_strategy: Literal["replace", "merge"] = "merge",
|
|
655
|
+
) -> None:
|
|
656
|
+
"""
|
|
657
|
+
Update custom data for a specific figure in a volume.
|
|
658
|
+
|
|
659
|
+
:param figure_id: ID of the figure to update.
|
|
660
|
+
:type figure_id: int
|
|
661
|
+
:param custom_data: Custom data to update.
|
|
662
|
+
:type custom_data: Dict[str, str]
|
|
663
|
+
:param update_strategy: Strategy to apply, either "replace" or "merge".
|
|
664
|
+
:type update_strategy: Literal["replace", "merge"]
|
|
665
|
+
:return: None
|
|
666
|
+
:rtype: :class:`NoneType`
|
|
667
|
+
|
|
668
|
+
"""
|
|
669
|
+
data = {
|
|
670
|
+
ApiField.ID: figure_id,
|
|
671
|
+
ApiField.CUSTOM_DATA: custom_data,
|
|
672
|
+
ApiField.UPDATE_STRATEGY: update_strategy,
|
|
673
|
+
}
|
|
674
|
+
self._api.post("figures.custom-data.update", data)
|
|
@@ -83,13 +83,13 @@ class BaseConverter:
|
|
|
83
83
|
item_path: str,
|
|
84
84
|
ann_data: Union[str, dict] = None,
|
|
85
85
|
shape: Union[Tuple, List] = None,
|
|
86
|
-
custom_data: dict =
|
|
86
|
+
custom_data: Optional[dict] = None,
|
|
87
87
|
):
|
|
88
88
|
self._path: str = item_path
|
|
89
89
|
self._name: str = None
|
|
90
90
|
self._ann_data: Union[str, dict, list] = ann_data
|
|
91
91
|
self._shape: Union[Tuple, List] = shape
|
|
92
|
-
self._custom_data: dict = custom_data
|
|
92
|
+
self._custom_data: dict = custom_data or {}
|
|
93
93
|
|
|
94
94
|
@property
|
|
95
95
|
def name(self) -> str:
|
|
@@ -2,7 +2,8 @@ import os
|
|
|
2
2
|
from collections import defaultdict
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
-
from supervisely import ProjectMeta, logger
|
|
5
|
+
from supervisely import Api, ProjectMeta, logger
|
|
6
|
+
from supervisely._utils import batched, is_development
|
|
6
7
|
from supervisely.annotation.obj_class import ObjClass
|
|
7
8
|
from supervisely.convert.volume.nii import nii_volume_helper as helper
|
|
8
9
|
from supervisely.convert.volume.nii.nii_volume_converter import NiiConverter
|
|
@@ -12,7 +13,6 @@ from supervisely.io.fs import get_file_ext, get_file_name, list_files_recursivel
|
|
|
12
13
|
from supervisely.volume.volume import is_nifti_file
|
|
13
14
|
from supervisely.volume_annotation.volume_annotation import VolumeAnnotation
|
|
14
15
|
from supervisely.volume_annotation.volume_object import VolumeObject
|
|
15
|
-
from supervisely._utils import batched, is_development
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
@@ -83,6 +83,12 @@ class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
|
83
83
|
ann_dict = defaultdict(list)
|
|
84
84
|
cls_color_map = None
|
|
85
85
|
|
|
86
|
+
ann_to_score_path = {}
|
|
87
|
+
csv_files = list_files_recursively(self._input_data, [".csv"], None, True)
|
|
88
|
+
csv_nameparts = {
|
|
89
|
+
helper.parse_name_parts(os.path.basename(file)): file for file in csv_files
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
for root, _, files in os.walk(self._input_data):
|
|
87
93
|
if converted_dir_name in root:
|
|
88
94
|
continue
|
|
@@ -91,17 +97,25 @@ class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
|
91
97
|
if is_nifti_file(path):
|
|
92
98
|
name_parts = helper.parse_name_parts(file)
|
|
93
99
|
if name_parts is None:
|
|
94
|
-
logger.
|
|
100
|
+
logger.debug(
|
|
95
101
|
f"File recognized as NIfTI, but failed to parse plane identifier from name. Path: {path}",
|
|
96
102
|
)
|
|
97
103
|
continue
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
if name_parts.is_ann:
|
|
105
|
+
dict_to_use = ann_dict
|
|
106
|
+
score_path = helper.find_best_name_match(
|
|
107
|
+
name_parts, list(csv_nameparts.keys())
|
|
108
|
+
)
|
|
109
|
+
if score_path is not None:
|
|
110
|
+
full_score_path = csv_nameparts[score_path]
|
|
111
|
+
ann_to_score_path[name_parts.full_name] = full_score_path
|
|
112
|
+
else:
|
|
113
|
+
dict_to_use = volumes_dict
|
|
114
|
+
|
|
115
|
+
if name_parts.patient_uuid is None and name_parts.case_uuid is None:
|
|
116
|
+
key = name_parts.plane
|
|
117
|
+
else:
|
|
118
|
+
key = f"{name_parts.plane}_{name_parts.patient_uuid}_{name_parts.case_uuid}"
|
|
105
119
|
dict_to_use[key].append(path)
|
|
106
120
|
ext = get_file_ext(path)
|
|
107
121
|
if ext == ".txt":
|
|
@@ -113,7 +127,17 @@ class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
|
113
127
|
for key, paths in volumes_dict.items():
|
|
114
128
|
if len(paths) == 1:
|
|
115
129
|
item = self.Item(item_path=paths[0])
|
|
130
|
+
name_parts = helper.parse_name_parts(os.path.basename(item.path))
|
|
116
131
|
item.ann_data = ann_dict.get(key, [])
|
|
132
|
+
|
|
133
|
+
ann_path = os.path.basename(item.ann_data[0]) if item.ann_data else None
|
|
134
|
+
if ann_path in ann_to_score_path:
|
|
135
|
+
score_path = ann_to_score_path[ann_path]
|
|
136
|
+
try:
|
|
137
|
+
scores = helper.get_scores_from_table(score_path, name_parts.plane)
|
|
138
|
+
item.custom_data["scores"] = scores
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.warning(f"Failed to read scores from {score_path}: {e}")
|
|
117
141
|
item.is_semantic = len(item.ann_data) == 1
|
|
118
142
|
if cls_color_map is not None:
|
|
119
143
|
item.custom_data["cls_color_map"] = cls_color_map
|
|
@@ -123,12 +147,23 @@ class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
|
123
147
|
f"Found {len(paths)} volumes with key {key}. Will try to match them by directories."
|
|
124
148
|
)
|
|
125
149
|
for path in paths:
|
|
150
|
+
name_parts = helper.parse_name_parts(os.path.basename(path))
|
|
126
151
|
item = self.Item(item_path=path)
|
|
127
152
|
possible_ann_paths = []
|
|
128
153
|
for ann_path in ann_dict.get(key, []):
|
|
129
154
|
if Path(ann_path).parent == Path(path).parent:
|
|
130
155
|
possible_ann_paths.append(ann_path)
|
|
131
156
|
item.ann_data = possible_ann_paths
|
|
157
|
+
scores_paths = [
|
|
158
|
+
ann_to_score_path.get(ann_name, None) for ann_name in possible_ann_paths
|
|
159
|
+
]
|
|
160
|
+
scores_paths = [path for path in scores_paths if path is not None]
|
|
161
|
+
if scores_paths:
|
|
162
|
+
try:
|
|
163
|
+
scores = helper.get_scores_from_table(scores_paths[0], name_parts.plane)
|
|
164
|
+
item.custom_data["scores"] = scores
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(f"Failed to read scores from {scores_paths[0]}: {e}")
|
|
132
167
|
item.is_semantic = len(possible_ann_paths) == 1
|
|
133
168
|
if cls_color_map is not None:
|
|
134
169
|
item.custom_data["cls_color_map"] = cls_color_map
|
|
@@ -149,6 +184,7 @@ class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
|
149
184
|
try:
|
|
150
185
|
objs = []
|
|
151
186
|
spatial_figures = []
|
|
187
|
+
scores = item.custom_data.get("scores", {})
|
|
152
188
|
for idx, ann_path in enumerate(item.ann_data, start=1):
|
|
153
189
|
for mask, pixel_value in helper.get_annotation_from_nii(ann_path):
|
|
154
190
|
class_id = pixel_value if item.is_semantic else idx
|
|
@@ -170,7 +206,9 @@ class NiiPlaneStructuredConverter(NiiConverter, VolumeConverter):
|
|
|
170
206
|
meta = meta.add_obj_class(obj_class)
|
|
171
207
|
self._meta_changed = True
|
|
172
208
|
self._meta = meta
|
|
173
|
-
|
|
209
|
+
obj_scores = scores.get(class_id, {})
|
|
210
|
+
obj_scores = {k: v for k, v in obj_scores.items()}
|
|
211
|
+
obj = VolumeObject(obj_class, mask_3d=mask, custom_data=obj_scores)
|
|
174
212
|
spatial_figures.append(obj.figure)
|
|
175
213
|
objs.append(obj)
|
|
176
214
|
return VolumeAnnotation(item.volume_meta, objects=objs, spatial_figures=spatial_figures)
|
|
@@ -210,7 +248,7 @@ class NiiPlaneStructuredAnnotationConverter(NiiConverter, VolumeConverter):
|
|
|
210
248
|
|
|
211
249
|
def validate_format(self) -> bool:
|
|
212
250
|
try:
|
|
213
|
-
from nibabel import
|
|
251
|
+
from nibabel import filebasedimages, load
|
|
214
252
|
except ImportError:
|
|
215
253
|
raise ImportError(
|
|
216
254
|
"No module named nibabel. Please make sure that module is installed from pip and try again."
|
|
@@ -235,8 +273,8 @@ class NiiPlaneStructuredAnnotationConverter(NiiConverter, VolumeConverter):
|
|
|
235
273
|
for root, _, files in os.walk(self._input_data):
|
|
236
274
|
for file in files:
|
|
237
275
|
path = os.path.join(root, file)
|
|
276
|
+
name_parts = helper.parse_name_parts(file)
|
|
238
277
|
if is_nii(file):
|
|
239
|
-
name_parts = helper.parse_name_parts(file)
|
|
240
278
|
if name_parts is None or not name_parts.is_ann:
|
|
241
279
|
continue
|
|
242
280
|
try:
|
|
@@ -193,7 +193,7 @@ class NiiConverter(VolumeConverter):
|
|
|
193
193
|
volume_np, volume_meta = read_nrrd_serie_volume_np(item.path)
|
|
194
194
|
progress_nrrd = tqdm_sly(
|
|
195
195
|
desc=f"Uploading volume '{item.name}'",
|
|
196
|
-
total=sum(volume_np.shape),
|
|
196
|
+
total=sum(volume_np.shape) + 1,
|
|
197
197
|
leave=True if progress_cb is None else False,
|
|
198
198
|
position=1,
|
|
199
199
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from collections import defaultdict, namedtuple
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Generator
|
|
4
|
+
from typing import Generator, List, Union
|
|
5
5
|
|
|
6
6
|
import nrrd
|
|
7
7
|
import numpy as np
|
|
@@ -17,6 +17,7 @@ VOLUME_NAME = "anatomic"
|
|
|
17
17
|
LABEL_NAME = ["inference", "label", "annotation", "mask", "segmentation"]
|
|
18
18
|
MASK_PIXEL_VALUE = "Mask pixel value: "
|
|
19
19
|
|
|
20
|
+
|
|
20
21
|
class PlanePrefix(str, StrEnum):
|
|
21
22
|
"""Prefix for plane names."""
|
|
22
23
|
|
|
@@ -117,15 +118,79 @@ def get_annotation_from_nii(path: str) -> Generator[Mask3D, None, None]:
|
|
|
117
118
|
yield mask, class_id
|
|
118
119
|
|
|
119
120
|
|
|
121
|
+
def get_scores_from_table(csv_file_path: str, plane: str) -> dict:
|
|
122
|
+
"""Get scores from CSV table and return nested dictionary structure.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
csv_file_path: Path to the CSV file containing layer scores
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Nested dictionary with structure:
|
|
129
|
+
{
|
|
130
|
+
"label_index": {
|
|
131
|
+
"slice_index": {
|
|
132
|
+
"127": {
|
|
133
|
+
"score": float_value,
|
|
134
|
+
"comment": ""
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
import csv
|
|
141
|
+
|
|
142
|
+
if plane == PlanePrefix.CORONAL:
|
|
143
|
+
plane = "0-1-0"
|
|
144
|
+
elif plane == PlanePrefix.SAGITTAL:
|
|
145
|
+
plane = "1-0-0"
|
|
146
|
+
elif plane == PlanePrefix.AXIAL:
|
|
147
|
+
plane = "0-0-1"
|
|
148
|
+
|
|
149
|
+
result = defaultdict(lambda: defaultdict(dict))
|
|
150
|
+
|
|
151
|
+
if not os.path.exists(csv_file_path):
|
|
152
|
+
logger.warning(f"CSV file not found: {csv_file_path}")
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
with open(csv_file_path, "r") as file:
|
|
157
|
+
reader = csv.DictReader(file)
|
|
158
|
+
label_columns = [col for col in reader.fieldnames if col.startswith("Label-")]
|
|
159
|
+
|
|
160
|
+
for row in reader:
|
|
161
|
+
frame_idx = int(row["Layer"]) - 1 # Assuming Layer is 1-indexed in CSV
|
|
162
|
+
|
|
163
|
+
for label_col in label_columns:
|
|
164
|
+
label_index = int(label_col.split("-")[1])
|
|
165
|
+
score = f"{float(row[label_col]):.2f}"
|
|
166
|
+
result[label_index][plane][frame_idx] = {"score": score, "comment": None}
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.warning(f"Failed to read CSV file {csv_file_path}: {e}")
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
120
175
|
class AnnotationMatcher:
|
|
121
176
|
def __init__(self, items, dataset_id):
|
|
122
177
|
self._items = items
|
|
123
178
|
self._ds_id = dataset_id
|
|
124
179
|
self._ann_paths = defaultdict(list)
|
|
125
|
-
|
|
126
180
|
self._item_by_filename = {}
|
|
127
181
|
self._item_by_path = {}
|
|
128
182
|
|
|
183
|
+
self.set_items(items)
|
|
184
|
+
|
|
185
|
+
self._project_wide = False
|
|
186
|
+
self._volumes = None
|
|
187
|
+
|
|
188
|
+
def set_items(self, items):
|
|
189
|
+
self._items = items
|
|
190
|
+
self._ann_paths.clear()
|
|
191
|
+
self._item_by_filename.clear()
|
|
192
|
+
self._item_by_path.clear()
|
|
193
|
+
|
|
129
194
|
for item in items:
|
|
130
195
|
path = Path(item.ann_data)
|
|
131
196
|
dataset_name = path.parts[-2]
|
|
@@ -135,9 +200,6 @@ class AnnotationMatcher:
|
|
|
135
200
|
self._item_by_filename[filename] = item
|
|
136
201
|
self._item_by_path[(dataset_name, filename)] = item
|
|
137
202
|
|
|
138
|
-
self._project_wide = False
|
|
139
|
-
self._volumes = None
|
|
140
|
-
|
|
141
203
|
def get_volumes(self, api: Api):
|
|
142
204
|
dataset_info = api.dataset.get_info_by_id(self._ds_id)
|
|
143
205
|
datasets = {dataset_info.name: dataset_info}
|
|
@@ -166,11 +228,8 @@ class AnnotationMatcher:
|
|
|
166
228
|
|
|
167
229
|
def match_items(self):
|
|
168
230
|
"""Match annotation files with corresponding volumes using regex-based matching."""
|
|
169
|
-
import re
|
|
170
|
-
|
|
171
231
|
item_to_volume = {}
|
|
172
232
|
|
|
173
|
-
# Perform matching
|
|
174
233
|
for dataset_name, volumes in self._volumes.items():
|
|
175
234
|
volume_names = [parse_name_parts(name) for name in list(volumes.keys())]
|
|
176
235
|
_volume_names = [vol for vol in volume_names if vol is not None]
|
|
@@ -191,7 +250,7 @@ class AnnotationMatcher:
|
|
|
191
250
|
if ann_name is None:
|
|
192
251
|
logger.warning(f"Failed to parse annotation name: {ann_file}")
|
|
193
252
|
continue
|
|
194
|
-
match =
|
|
253
|
+
match = find_best_name_match(ann_name, volume_names)
|
|
195
254
|
if match is not None:
|
|
196
255
|
if match.plane != ann_name.plane:
|
|
197
256
|
logger.warning(
|
|
@@ -200,7 +259,6 @@ class AnnotationMatcher:
|
|
|
200
259
|
continue
|
|
201
260
|
item_to_volume[self._item_by_filename[ann_file]] = volumes[match.full_name]
|
|
202
261
|
|
|
203
|
-
# Mark volumes having only one matching item as semantic and validate shape.
|
|
204
262
|
volume_to_items = defaultdict(list)
|
|
205
263
|
for item, volume in item_to_volume.items():
|
|
206
264
|
volume_to_items[volume.id].append(item)
|
|
@@ -306,9 +364,11 @@ def parse_name_parts(full_name: str) -> NameParts:
|
|
|
306
364
|
is_ann = False
|
|
307
365
|
if VOLUME_NAME in full_name:
|
|
308
366
|
type = "anatomic"
|
|
309
|
-
|
|
367
|
+
elif any(part in full_name for part in LABEL_NAME):
|
|
310
368
|
type = next((part for part in LABEL_NAME if part in full_name), None)
|
|
311
369
|
is_ann = type is not None
|
|
370
|
+
elif "score" in name_no_ext or get_file_ext(full_name) == ".csv":
|
|
371
|
+
type = "score"
|
|
312
372
|
|
|
313
373
|
if type is None:
|
|
314
374
|
return
|
|
@@ -367,58 +427,58 @@ def parse_name_parts(full_name: str) -> NameParts:
|
|
|
367
427
|
)
|
|
368
428
|
|
|
369
429
|
|
|
370
|
-
def
|
|
430
|
+
def find_best_name_match(item: NameParts, pool: List[NameParts]) -> Union[NameParts, None]:
|
|
371
431
|
"""
|
|
372
|
-
Finds the best matching NameParts object from `
|
|
432
|
+
Finds the best matching NameParts object from `pool` for the given annotation NameParts `item`.
|
|
373
433
|
Prefers an exact match where all fields except `type` are the same, and `type` is 'anatomic'.
|
|
374
434
|
Returns the matched NameParts object or None if not found.
|
|
375
435
|
"""
|
|
376
|
-
|
|
377
|
-
|
|
436
|
+
pool_item_names = [i.full_name for i in pool]
|
|
437
|
+
item_name = item.full_name
|
|
378
438
|
# Prefer exact match except for type
|
|
379
|
-
for
|
|
380
|
-
if
|
|
439
|
+
for i in pool:
|
|
440
|
+
if i.name_no_ext == item.name_no_ext.replace(item.type, i.type):
|
|
381
441
|
logger.debug(
|
|
382
|
-
"Found exact match
|
|
383
|
-
extra={"
|
|
442
|
+
"Found exact match.",
|
|
443
|
+
extra={"item": item_name, "pool_item": i.full_name},
|
|
384
444
|
)
|
|
385
|
-
return
|
|
445
|
+
return i
|
|
386
446
|
|
|
387
447
|
logger.debug(
|
|
388
448
|
"Failed to find exact match, trying to find a fallback match UUIDs.",
|
|
389
|
-
extra={"
|
|
449
|
+
extra={"item": item_name, "pool_items": pool_item_names},
|
|
390
450
|
)
|
|
391
451
|
|
|
392
452
|
# Fallback: match by plane and patient_uuid, type='anatomic'
|
|
393
|
-
for
|
|
453
|
+
for i in pool:
|
|
394
454
|
if (
|
|
395
|
-
|
|
396
|
-
and
|
|
397
|
-
and
|
|
455
|
+
i.plane == item.plane
|
|
456
|
+
and i.patient_uuid == item.patient_uuid
|
|
457
|
+
and i.case_uuid == item.case_uuid
|
|
398
458
|
):
|
|
399
459
|
logger.debug(
|
|
400
|
-
"Found fallback match for
|
|
401
|
-
extra={"
|
|
460
|
+
"Found fallback match for item by UUIDs.",
|
|
461
|
+
extra={"item": item_name, "i": i.full_name},
|
|
402
462
|
)
|
|
403
|
-
return
|
|
463
|
+
return i
|
|
404
464
|
|
|
405
465
|
logger.debug(
|
|
406
466
|
"Failed to find fallback match, trying to find a fallback match by plane.",
|
|
407
|
-
extra={"
|
|
467
|
+
extra={"item": item_name, "pool_items": pool_item_names},
|
|
408
468
|
)
|
|
409
469
|
|
|
410
470
|
# Fallback: match by plane and type='anatomic'
|
|
411
|
-
for
|
|
412
|
-
if
|
|
471
|
+
for i in pool:
|
|
472
|
+
if i.plane == item.plane:
|
|
413
473
|
logger.debug(
|
|
414
|
-
"Found fallback match for
|
|
415
|
-
extra={"
|
|
474
|
+
"Found fallback match for item by plane.",
|
|
475
|
+
extra={"item": item_name, "i": i.full_name},
|
|
416
476
|
)
|
|
417
|
-
return
|
|
477
|
+
return i
|
|
418
478
|
|
|
419
479
|
logger.debug(
|
|
420
|
-
"Failed to find any match for
|
|
421
|
-
extra={"
|
|
480
|
+
"Failed to find any match for item.",
|
|
481
|
+
extra={"item": item_name, "pool_items": pool_item_names},
|
|
422
482
|
)
|
|
423
483
|
|
|
424
484
|
return None
|