supervisely 6.73.368__py3-none-any.whl → 6.73.369__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.
@@ -2,7 +2,6 @@
2
2
  import os
3
3
  import re
4
4
  import tempfile
5
- from collections import OrderedDict
6
5
  from typing import Dict, List
7
6
  from uuid import UUID
8
7
 
@@ -470,7 +469,7 @@ class VolumeFigureApi(FigureApi):
470
469
  for figure in figures:
471
470
  if figure.key() == key:
472
471
  geometry_data = figure.geometry.data
473
- header = self._create_header_for_geometry(figure.geometry)
472
+ header = figure.geometry.create_header()
474
473
  geometry_bytes = encode(geometry_data.astype(uint8), header)
475
474
  self.upload_sf_geometries([key], {key: geometry_bytes}, key_id_map)
476
475
 
@@ -625,19 +624,6 @@ class VolumeFigureApi(FigureApi):
625
624
  geometry = Mask3D.create_from_file(figure_path)
626
625
  spatial_figure._set_3d_geometry(geometry)
627
626
 
628
- def _create_header_for_geometry(self, geometry: Mask3D) -> OrderedDict:
629
- """
630
- Create header for encoding Mask3D to NRRD bytes
631
- """
632
- header = OrderedDict()
633
- if geometry._space is not None:
634
- header["space"] = geometry._space
635
- if geometry._space_directions is not None:
636
- header["space directions"] = geometry._space_directions
637
- if geometry._space_origin is not None:
638
- header["space origin"] = geometry._space_origin.to_json()["space_origin"]
639
- return header
640
-
641
627
  def download(
642
628
  self, dataset_id: int, volume_ids: List[int] = None, skip_geometry: bool = False, **kwargs
643
629
  ) -> Dict[int, List[FigureInfo]]:
@@ -1,10 +1,10 @@
1
1
  import os
2
+ from collections import defaultdict, namedtuple
3
+ from pathlib import Path
2
4
  from typing import Generator
3
5
 
4
6
  import nrrd
5
7
  import numpy as np
6
- from pathlib import Path
7
- from collections import defaultdict, namedtuple
8
8
 
9
9
  from supervisely import Api
10
10
  from supervisely.collection.str_enum import StrEnum
@@ -107,13 +107,13 @@ def nifti_to_nrrd(nii_file_path: str, converted_dir: str) -> str:
107
107
  def get_annotation_from_nii(path: str) -> Generator[Mask3D, None, None]:
108
108
  """Get annotation from NIfTI 3D volume file."""
109
109
 
110
- data, _ = convert_3d_nifti_to_nrrd(path)
110
+ data, header = convert_3d_nifti_to_nrrd(path)
111
111
  unique_classes = np.unique(data)
112
112
 
113
113
  for class_id in unique_classes:
114
114
  if class_id == 0:
115
115
  continue
116
- mask = Mask3D(data == class_id)
116
+ mask = Mask3D(data == class_id, volume_header=header)
117
117
  yield mask, class_id
118
118
 
119
119
 
@@ -12,6 +12,8 @@ INTERIOR = "interior"
12
12
  MULTICHANNEL_BITMAP = "multichannelBitmap"
13
13
  ORIGIN = "origin"
14
14
  SPACE_ORIGIN = "space_origin"
15
+ SPACE = "space"
16
+ SPACE_DIRECTIONS = "space_directions"
15
17
  POINTS = "points"
16
18
  ROWS = "rows"
17
19
  TYPE = "type"
@@ -6,9 +6,9 @@ from __future__ import annotations
6
6
  import base64
7
7
  import gzip
8
8
  import tempfile
9
+ from collections import OrderedDict
9
10
  from typing import Dict, List, Literal, Optional, Tuple, Union
10
11
 
11
- import nrrd
12
12
  import numpy as np
13
13
 
14
14
  from supervisely import logger
@@ -22,6 +22,8 @@ from supervisely.geometry.constants import (
22
22
  ID,
23
23
  LABELER_LOGIN,
24
24
  MASK_3D,
25
+ SPACE,
26
+ SPACE_DIRECTIONS,
25
27
  SPACE_ORIGIN,
26
28
  UPDATED_AT,
27
29
  )
@@ -183,6 +185,10 @@ class Mask3D(Geometry):
183
185
  :type updated_at: str, optional
184
186
  :param created_at: Date and Time when Mask 3D was created. Date Format is the same as in "updated_at" parameter.
185
187
  :type created_at: str, optional
188
+ :param volume_header: NRRD header dictionary. Optional.
189
+ :type volume_header: dict, optional
190
+ :param convert_to_ras: If True, converts the mask to RAS orientation. Default is True.
191
+ :type convert_to_ras: bool, optional
186
192
  :raises: :class:`ValueError`, if data is not bool or no pixels set to True in data
187
193
  :Usage example:
188
194
 
@@ -219,6 +225,8 @@ class Mask3D(Geometry):
219
225
  labeler_login: Optional[str] = None,
220
226
  updated_at: Optional[str] = None,
221
227
  created_at: Optional[str] = None,
228
+ volume_header: Optional[Dict] = None,
229
+ convert_to_ras: bool = True,
222
230
  ):
223
231
  super().__init__(
224
232
  sly_id=sly_id,
@@ -253,6 +261,98 @@ class Mask3D(Geometry):
253
261
  self._space = None
254
262
  self._space_directions = None
255
263
 
264
+ if volume_header is not None:
265
+ self.set_volume_space_meta(volume_header)
266
+ if self.space is not None and self.space != "right-anterior-superior":
267
+ if convert_to_ras:
268
+ self.orient_ras()
269
+ else:
270
+ logger.debug(
271
+ "Mask3D is not in RAS orientation. It is recommended to use RAS orientation for 3D masks."
272
+ )
273
+
274
+ @property
275
+ def space_origin(self) -> Optional[List[float]]:
276
+ """
277
+ Get the space origin of the Mask3D as a list of floats.
278
+
279
+ :return: Space origin of the Mask3D.
280
+ :rtype: List[float] or None
281
+ """
282
+ if self._space_origin is not None:
283
+ return [self._space_origin.x, self._space_origin.y, self._space_origin.z]
284
+ return None
285
+
286
+ @space_origin.setter
287
+ def space_origin(self, value: Union[PointVolume, List[float], np.array]):
288
+ """
289
+ Set the space origin of the Mask3D.
290
+
291
+ :param value: Space origin of the Mask3D. If provided as a list or array, it should contain 3 floats in the order [x, y, z].
292
+ :type value: :class:`PointVolume<PointVolume>` or List[float]
293
+ """
294
+ if isinstance(value, PointVolume):
295
+ self._space_origin = value
296
+ elif isinstance(value, list) and len(value) == 3:
297
+ self._space_origin = PointVolume(x=value[0], y=value[1], z=value[2])
298
+ elif isinstance(value, np.ndarray) and value.shape == (3,):
299
+ self._space_origin = PointVolume(x=value[0], y=value[1], z=value[2])
300
+ else:
301
+ raise ValueError("Space origin must be a PointVolume or a list of 3 floats.")
302
+
303
+ @property
304
+ def space(self) -> Optional[str]:
305
+ """
306
+ Get the space of the Mask3D.
307
+
308
+ :return: Space of the Mask3D.
309
+ :rtype: :class:`str`
310
+ """
311
+ return self._space
312
+
313
+ @space.setter
314
+ def space(self, value: str):
315
+ """
316
+ Set the space of the Mask3D.
317
+
318
+ :param value: Space of the Mask3D.
319
+ :type value: str
320
+ """
321
+ if not isinstance(value, str):
322
+ raise ValueError("Space must be a string.")
323
+ self._space = value
324
+
325
+ @property
326
+ def space_directions(self) -> Optional[List[List[float]]]:
327
+ """
328
+ Get the space directions of the Mask3D.
329
+
330
+ :return: Space directions of the Mask3D.
331
+ :rtype: :class:`List[List[float]]`
332
+ """
333
+ return self._space_directions
334
+
335
+ @space_directions.setter
336
+ def space_directions(self, value: Union[List[List[float]], np.ndarray]):
337
+ """
338
+ Set the space directions of the Mask3D.
339
+
340
+ :param value: Space directions of the Mask3D. Should be a 3x3 array-like structure.
341
+ :type value: List[List[float]] or np.ndarray
342
+ """
343
+ if isinstance(value, np.ndarray):
344
+ if value.shape != (3, 3):
345
+ raise ValueError("Space directions must be a 3x3 array.")
346
+ self._space_directions = value.tolist()
347
+ elif (
348
+ isinstance(value, list)
349
+ and len(value) == 3
350
+ and all(isinstance(row, (list, np.ndarray)) and len(row) == 3 for row in value)
351
+ ):
352
+ self._space_directions = [list(row) for row in value]
353
+ else:
354
+ raise ValueError("Space directions must be a 3x3 array or list of lists.")
355
+
256
356
  @staticmethod
257
357
  def geometry_name():
258
358
  """Return geometry name"""
@@ -268,22 +368,8 @@ class Mask3D(Geometry):
268
368
  :param file_path: Path to nrrd file with data
269
369
  :type file_path: str
270
370
  """
271
- mask3d_data, mask3d_header = nrrd.read(file_path)
272
- figure.geometry.data = mask3d_data
273
- try:
274
- figure.geometry._space_origin = PointVolume(
275
- x=mask3d_header["space origin"][0],
276
- y=mask3d_header["space origin"][1],
277
- z=mask3d_header["space origin"][2],
278
- )
279
- figure.geometry._space = mask3d_header["space"]
280
- figure.geometry._space_directions = mask3d_header["space directions"]
281
- except KeyError as e:
282
- header_keys = ["'space'", "'space directions'", "'space origin'"]
283
- if str(e) in header_keys:
284
- logger.warning(
285
- f"The Mask3D geometry for figure ID '{get_file_name(file_path)}' doesn't contain optional space attributes that have similar names to {', '.join(header_keys)}. To set the values for these attributes, you can use information from the Volume associated with this figure object."
286
- )
371
+ mask3d = Mask3D.create_from_file(file_path)
372
+ figure._set_3d_geometry(mask3d)
287
373
  path_without_filename = "/".join(file_path.split("/")[:-1])
288
374
  remove_dir(path_without_filename)
289
375
 
@@ -295,22 +381,26 @@ class Mask3D(Geometry):
295
381
  :param file_path: Path to nrrd file with data
296
382
  :type file_path: str
297
383
  """
298
- mask3d_data, mask3d_header = nrrd.read(file_path)
299
- geometry = cls(data=mask3d_data)
300
- try:
301
- geometry._space_origin = PointVolume(
302
- x=mask3d_header["space origin"][0],
303
- y=mask3d_header["space origin"][1],
304
- z=mask3d_header["space origin"][2],
305
- )
306
- geometry._space = mask3d_header["space"]
307
- geometry._space_directions = mask3d_header["space directions"]
308
- except KeyError as e:
384
+ from supervisely.volume.volume import read_nrrd_serie_volume_np
385
+
386
+ mask3d_data, meta = read_nrrd_serie_volume_np(file_path)
387
+ direction = np.array(meta["directions"]).reshape(3, 3)
388
+ spacing = np.array(meta["spacing"])
389
+ space_directions = (direction.T * spacing[:, None]).tolist()
390
+ mask3d_header = {
391
+ "space": "right-anterior-superior",
392
+ "space directions": space_directions,
393
+ "space origin": meta.get("origin", None),
394
+ }
395
+
396
+ geometry = cls(data=mask3d_data, volume_header=mask3d_header)
397
+
398
+ fields_to_check = ["space", "space_directions", "space_origin"]
399
+ if any([getattr(geometry, value) is None for value in fields_to_check]):
309
400
  header_keys = ["'space'", "'space directions'", "'space origin'"]
310
- if str(e) in header_keys:
311
- logger.debug(
312
- f"The Mask3D geometry created from the file '{file_path}' doesn't contain optional space attributes that have similar names to {', '.join(header_keys)}. To set the values for these attributes, you can use information from the Volume associated with this figure object."
313
- )
401
+ logger.debug(
402
+ f"The Mask3D geometry created from the file '{file_path}' doesn't contain optional space attributes that have similar names to {', '.join(header_keys)}. To set the values for these attributes, you can use information from the Volume associated with this figure object."
403
+ )
314
404
  return geometry
315
405
 
316
406
  @classmethod
@@ -323,7 +413,7 @@ class Mask3D(Geometry):
323
413
  :return: A Mask3D geometry object.
324
414
  :rtype: Mask3D
325
415
  """
326
- with tempfile.NamedTemporaryFile(delete=True) as temp_file:
416
+ with tempfile.NamedTemporaryFile(delete=True, suffix=".nrrd") as temp_file:
327
417
  temp_file.write(geometry_bytes)
328
418
  return cls.create_from_file(temp_file.name)
329
419
 
@@ -368,12 +458,14 @@ class Mask3D(Geometry):
368
458
  GEOMETRY_TYPE: self.name(),
369
459
  }
370
460
 
371
- if self._space_origin:
372
- res[f"{self._impl_json_class_name()}"][f"{SPACE_ORIGIN}"] = [
373
- self._space_origin.x,
374
- self._space_origin.y,
375
- self._space_origin.z,
376
- ]
461
+ if self.space_origin:
462
+ res[f"{self._impl_json_class_name()}"][f"{SPACE_ORIGIN}"] = self.space_origin
463
+
464
+ if self.space:
465
+ res[f"{self._impl_json_class_name()}"][f"{SPACE}"] = self.space
466
+
467
+ if self.space_directions:
468
+ res[f"{self._impl_json_class_name()}"][f"{SPACE_DIRECTIONS}"] = self.space_directions
377
469
 
378
470
  self._add_creation_info(res)
379
471
  return res
@@ -426,18 +518,30 @@ class Mask3D(Geometry):
426
518
  created_at = json_data.get(CREATED_AT, None)
427
519
  sly_id = json_data.get(ID, None)
428
520
  class_id = json_data.get(CLASS_ID, None)
429
- instance = cls(
521
+
522
+ header = {}
523
+
524
+ space_origin = json_data[json_root_key].get(SPACE_ORIGIN, None)
525
+ if space_origin is not None:
526
+ header["space origin"] = space_origin
527
+
528
+ space = json_data[json_root_key].get(SPACE, None)
529
+ if space is not None:
530
+ header["space"] = space
531
+
532
+ space_directions = json_data[json_root_key].get(SPACE_DIRECTIONS, None)
533
+ if space_directions is not None:
534
+ header["space directions"] = space_directions
535
+
536
+ return cls(
430
537
  data=data.astype(np.bool_),
431
538
  sly_id=sly_id,
432
539
  class_id=class_id,
433
540
  labeler_login=labeler_login,
434
541
  updated_at=updated_at,
435
542
  created_at=created_at,
543
+ volume_header=header,
436
544
  )
437
- if SPACE_ORIGIN in json_data[json_root_key]:
438
- x, y, z = json_data[json_root_key][SPACE_ORIGIN]
439
- instance._space_origin = PointVolume(x=x, y=y, z=z)
440
- return instance
441
545
 
442
546
  @classmethod
443
547
  def _impl_json_class_name(cls):
@@ -474,7 +578,7 @@ class Mask3D(Geometry):
474
578
  path_for_mesh = f"meshes/{figure_id}.nrrd"
475
579
  api.volume.figure.download_stl_meshes([figure_id], [path_for_mesh])
476
580
 
477
- mask3d_data, _ = nrrd.read(path_for_mesh)
581
+ mask3d_data, _ = sly.volume.volume.read_nrrd_serie_volume_np(path_for_mesh)
478
582
  encoded_string = sly.Mask3D.data_2_base64(mask3d_data)
479
583
 
480
584
  print(encoded_string)
@@ -619,3 +723,73 @@ class Mask3D(Geometry):
619
723
  continue
620
724
  geometries_dict[key] = geometry_bytes
621
725
  return geometries_dict
726
+
727
+ def set_volume_space_meta(self, header: Dict):
728
+ """
729
+ Set space, space directions, and space origin attributes from a NRRD header dictionary.
730
+
731
+ :param header: NRRD header dictionary.
732
+ :type header: dict
733
+ """
734
+ if "space" in header:
735
+ self.space = header["space"]
736
+ if "space directions" in header:
737
+ self.space_directions = header["space directions"]
738
+ if "space origin" in header:
739
+ self.space_origin = PointVolume(
740
+ x=header["space origin"][0],
741
+ y=header["space origin"][1],
742
+ z=header["space origin"][2],
743
+ )
744
+
745
+ def create_header(self) -> OrderedDict:
746
+ """
747
+ Create header for encoding Mask3D to NRRD bytes
748
+
749
+ :return: Header for NRRD file
750
+ :rtype: OrderedDict
751
+ """
752
+ header = OrderedDict()
753
+ if self.space is not None:
754
+ header["space"] = self.space
755
+ if self.space_directions is not None:
756
+ header["space directions"] = self.space_directions
757
+ if self.space_origin is not None:
758
+ header["space origin"] = self.space_origin
759
+ return header
760
+
761
+ def orient_ras(self) -> None:
762
+ """
763
+ Transforms the mask data and updates spatial metadata (origin, directions, spacing)
764
+ to align with the RAS coordinate system using SimpleITK.
765
+
766
+ :rtype: None
767
+ """
768
+ import SimpleITK as sitk
769
+
770
+ from supervisely.volume.volume import _sitk_image_orient_ras
771
+
772
+ sitk_volume = sitk.GetImageFromArray(self.data)
773
+ if self.space_origin is not None:
774
+ sitk_volume.SetOrigin(self.space_origin)
775
+ if self.space_directions is not None:
776
+ # Convert space directions to spacing and direction
777
+ space_directions = np.array(self.space_directions)
778
+ spacing = np.linalg.norm(space_directions, axis=1)
779
+ direction = space_directions / spacing[:, np.newaxis]
780
+ sitk_volume.SetSpacing(spacing)
781
+ sitk_volume.SetDirection(direction.flatten())
782
+
783
+ sitk_volume = _sitk_image_orient_ras(sitk_volume)
784
+
785
+ # Extract transformed data and update object
786
+ self.data = sitk.GetArrayFromImage(sitk_volume)
787
+ new_direction = np.array(sitk_volume.GetDirection()).reshape(3, 3)
788
+ new_spacing = np.array(sitk_volume.GetSpacing())
789
+ new_space_directions = (new_direction.T * new_spacing[:, None]).tolist()
790
+ new_header = {
791
+ "space": "right-anterior-superior",
792
+ "space directions": new_space_directions,
793
+ "space origin": sitk_volume.GetOrigin(),
794
+ }
795
+ self.set_volume_space_meta(new_header)
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
  import os
6
- from typing import List, Tuple, Union
6
+ from typing import List, Optional, Tuple, Union
7
7
 
8
8
  import numpy as np
9
9
  import pydicom
@@ -15,6 +15,7 @@ import supervisely.volume.nrrd_encoder as nrrd_encoder
15
15
  from supervisely import logger
16
16
  from supervisely.geometry.mask_3d import Mask3D
17
17
  from supervisely.io.fs import get_file_ext, get_file_name, list_files_recursively
18
+ from supervisely.volume.stl_converter import matrix_from_nrrd_header
18
19
 
19
20
  # Do NOT use directly for extension validation. Use is_valid_ext() / has_valid_ext() below instead.
20
21
  ALLOWED_VOLUME_EXTENSIONS = [".nrrd", ".dcm"]
@@ -455,7 +456,7 @@ def inspect_dicom_series(root_dir: str, logging: bool = True) -> dict:
455
456
  return found_series
456
457
 
457
458
 
458
- def _sitk_image_orient_ras(sitk_volume):
459
+ def _sitk_image_orient_ras(sitk_volume: sitk.Image) -> sitk.Image:
459
460
  import SimpleITK as sitk
460
461
 
461
462
  if sitk_volume.GetDimension() == 4 and sitk_volume.GetSize()[3] == 1:
@@ -704,7 +705,7 @@ def read_nrrd_serie_volume(path: str) -> Tuple[sitk.Image, dict]:
704
705
  """
705
706
  Read NRRD volume with given path.
706
707
 
707
- :param path: Paths to DICOM volume files.
708
+ :param path: Path to NRRD volume files.
708
709
  :type path: List[str]
709
710
  :return: Volume data in SimpleITK.Image format and dictionary with metadata.
710
711
  :rtype: Tuple[SimpleITK.Image, dict]
@@ -741,12 +742,12 @@ def read_nrrd_serie_volume(path: str) -> Tuple[sitk.Image, dict]:
741
742
  return sitk_volume, meta
742
743
 
743
744
 
744
- def read_nrrd_serie_volume_np(paths: List[str]) -> Tuple[np.ndarray, dict]:
745
+ def read_nrrd_serie_volume_np(paths: str) -> Tuple[np.ndarray, dict]:
745
746
  """
746
747
  Read NRRD volume with given path.
747
748
 
748
- :param path: Paths to NRRD volume file.
749
- :type path: List[str]
749
+ :param paths: Path to NRRD volume file.
750
+ :type paths: str
750
751
  :return: Volume data in NumPy array format and dictionary with metadata.
751
752
  :rtype: Tuple[np.ndarray, dict]
752
753
  :Usage example:
@@ -868,17 +869,18 @@ def is_nifti_file(path: str) -> bool:
868
869
 
869
870
  def convert_3d_geometry_to_mesh(
870
871
  geometry: Mask3D,
871
- spacing: tuple = None,
872
+ spacing: tuple = (1.0, 1.0, 1.0),
872
873
  level: float = 0.5,
873
874
  apply_decimation: bool = False,
874
875
  decimation_fraction: float = 0.5,
876
+ volume_meta: Optional[dict] = None,
875
877
  ) -> Trimesh:
876
878
  """
877
879
  Converts a 3D geometry (Mask3D) to a Trimesh mesh.
878
880
 
879
881
  :param geometry: The 3D geometry to convert.
880
882
  :type geometry: supervisely.geometry.mask_3d.Mask3D
881
- :param spacing: Voxel spacing in (x, y, z). Default is taken from geometry meta.
883
+ :param spacing: Voxel spacing in (x, y, z).
882
884
  :type spacing: tuple
883
885
  :param level: Isosurface value for marching cubes. Default is 0.5.
884
886
  :type level: float
@@ -886,6 +888,8 @@ def convert_3d_geometry_to_mesh(
886
888
  :type apply_decimation: bool
887
889
  :param decimation_fraction: Fraction of faces to keep if decimation is applied. Default is 0.5.
888
890
  :type decimation_fraction: float
891
+ :param volume_meta: Metadata of the volume. Used for mesh alignment if geometry lacks specific fields. Default is None.
892
+ :type volume_meta: dict, optional
889
893
  :return: The resulting Trimesh mesh.
890
894
  :rtype: trimesh.Trimesh
891
895
 
@@ -893,37 +897,40 @@ def convert_3d_geometry_to_mesh(
893
897
 
894
898
  .. code-block:: python
895
899
 
900
+ volume_header = nrrd.read_header("path/to/volume.nrrd")
896
901
  mask3d = Mask3D.create_from_file("path/to/mask3d")
897
- mesh = convert_3d_geometry_to_mesh(mask3d, spacing=(1.0, 1.0, 1.0), level=0.7, apply_decimation=True)
902
+ mesh = convert_3d_geometry_to_mesh(mask3d, spacing=(1.0, 1.0, 1.0), level=0.7, apply_decimation=True, volume_meta=volume_header)
898
903
  """
899
904
  from skimage import measure
900
905
 
901
- # Flip the mask along the x-axis to correct mirroring
902
- mask = np.flip(geometry.data, axis=0)
903
- if spacing is None:
904
- try:
905
- spacing = tuple(
906
- float(abs(direction[i])) for i, direction in enumerate(geometry._space_directions)
907
- )
908
- except Exception as e:
909
- logger.warning(
910
- "Failed to get spacing from geometry meta. Using (1.0, 1.0, 1.0).", exc_info=1
911
- )
912
- spacing = (1.0, 1.0, 1.0)
913
-
914
- # marching_cubes expects (z, y, x) order
915
- verts, faces, normals, _ = measure.marching_cubes(
916
- mask.astype(np.float32), level=level, spacing=spacing
917
- )
906
+ if volume_meta is None:
907
+ volume_meta = {}
908
+
909
+ space_directions = geometry.space_directions or volume_meta.get("space directions")
910
+ space_origin = geometry.space_origin or volume_meta.get("space origin")
911
+
912
+ verts, faces, normals, _ = measure.marching_cubes(geometry.data, level=level, spacing=spacing)
918
913
  mesh = Trimesh(vertices=verts, faces=faces, vertex_normals=normals, process=False)
919
914
 
920
915
  if apply_decimation and 0 < decimation_fraction < 1:
921
916
  mesh = mesh.simplify_quadric_decimation(int(len(mesh.faces) * decimation_fraction))
922
917
 
918
+ if space_directions is not None and space_origin is not None:
919
+ header = {
920
+ "space directions": space_directions,
921
+ "space origin": space_origin,
922
+ }
923
+ align_mesh_to_volume(mesh, header)
924
+
925
+ # flip x and y axes to match initial mask orientation
926
+ mesh.apply_transform(np.diag([-1, -1, 1, 1]))
927
+
928
+ mesh.fix_normals()
929
+
923
930
  return mesh
924
931
 
925
932
 
926
- def export_3d_as_mesh(geometry: Mask3D, output_path: str, kwargs=None):
933
+ def export_3d_as_mesh(geometry: Mask3D, output_path: str, **kwargs):
927
934
  """
928
935
  Exports the 3D mesh representation of the object to a file in either STL or OBJ format.
929
936
 
@@ -936,7 +943,7 @@ def export_3d_as_mesh(geometry: Mask3D, output_path: str, kwargs=None):
936
943
  - level (float): Isosurface value for marching cubes. Default is 0.5.
937
944
  - apply_decimation (bool): Whether to simplify the mesh. Default is False.
938
945
  - decimation_fraction (float): Fraction of faces to keep if decimation is applied. Default is 0.5.
939
- :type kwargs: dict, optional
946
+ - volume_meta (dict): Metadata of the volume. Used for mesh alignment if geometry lacks specific fields. Default is None.
940
947
  :return: None
941
948
 
942
949
  :Usage example:
@@ -946,14 +953,34 @@ def export_3d_as_mesh(geometry: Mask3D, output_path: str, kwargs=None):
946
953
  mask3d_path = "path/to/mask3d"
947
954
  mask3d = Mask3D.create_from_file(mask3d_path)
948
955
 
949
- mask3d.export_3d_as_mesh(mask3d, "output.stl", {"spacing": (1.0, 1.0, 1.0), "level": 0.7, "apply_decimation": True})
956
+ mask3d.export_3d_as_mesh(mask3d, "output.stl", spacing=(1.0, 1.0, 1.0), level=0.7, apply_decimation=True)
950
957
  """
951
958
 
952
- if kwargs is None:
953
- kwargs = {}
954
-
955
959
  if get_file_ext(output_path).lower() not in [".stl", ".obj"]:
956
960
  raise ValueError('File extension must be either ".stl" or ".obj"')
957
961
 
958
962
  mesh = convert_3d_geometry_to_mesh(geometry, **kwargs)
959
963
  mesh.export(output_path)
964
+
965
+
966
+ def align_mesh_to_volume(mesh: Trimesh, volume_header: dict) -> None:
967
+ """
968
+ Transforms the given mesh in-place using spatial information from an NRRD header.
969
+ The mesh will be tranformed to match the coordinate system defined in the header.
970
+
971
+ :param mesh: The mesh object to be transformed. The transformation is applied in-place.
972
+ :type mesh: Trimesh
973
+ :param volume_header: The NRRD header containing spatial metadata, including "space directions",
974
+ "space origin", and "space". Field "space" should be in the format of
975
+ "right-anterior-superior", "left-anterior-superior", etc.
976
+ :type volume_header: dict
977
+ :returns: None
978
+ :rtype: None
979
+ """
980
+ from supervisely.geometry.constants import SPACE_ORIGIN
981
+ from supervisely.geometry.mask_3d import PointVolume
982
+
983
+ if isinstance(volume_header["space origin"], PointVolume):
984
+ volume_header["space origin"] = volume_header["space origin"].to_json()[SPACE_ORIGIN]
985
+ transform_mat = matrix_from_nrrd_header(volume_header)
986
+ mesh.apply_transform(transform_mat)
@@ -675,6 +675,6 @@ class VolumeFigure(VideoFigure):
675
675
  )
676
676
 
677
677
  self.geometry.data = new_geometry.data
678
- self.geometry._space = new_geometry._space
679
- self.geometry._space_origin = new_geometry._space_origin
680
- self.geometry._space_directions = new_geometry._space_directions
678
+ self.geometry.space = new_geometry.space
679
+ self.geometry.space_origin = new_geometry.space_origin
680
+ self.geometry.space_directions = new_geometry.space_directions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: supervisely
3
- Version: 6.73.368
3
+ Version: 6.73.369
4
4
  Summary: Supervisely Python SDK.
5
5
  Home-page: https://github.com/supervisely/supervisely
6
6
  Author: Supervisely
@@ -77,7 +77,7 @@ supervisely/api/video/video_tag_api.py,sha256=wPe1HeJyg9kV1z2UJq6BEte5sKBoPJ2UGA
77
77
  supervisely/api/volume/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
78
78
  supervisely/api/volume/volume_annotation_api.py,sha256=NOHpLeqHLCeRs1KlXWoG91vtIXdUVTO69wh1ws0VmOQ,22246
79
79
  supervisely/api/volume/volume_api.py,sha256=rz_yaBbbTkVeAHmF449zPI8Va_YpDHfHYjXgjGAjMJg,55390
80
- supervisely/api/volume/volume_figure_api.py,sha256=WwmcMw7o3Nvyv52tzmz64yF-WJI0qzAU-zL2JlD7_w0,26039
80
+ supervisely/api/volume/volume_figure_api.py,sha256=upjIdiiQgOJ6och0KUg0rQo-q-PIipL5RX2V3fOBPvI,25437
81
81
  supervisely/api/volume/volume_object_api.py,sha256=F7pLV2MTlBlyN6fEKdxBSUatIMGWSuu8bWj3Hvcageo,2139
82
82
  supervisely/api/volume/volume_tag_api.py,sha256=yNGgXz44QBSW2VGlNDOVLqLXnH8Q2fFrxDFb_girYXA,3639
83
83
  supervisely/app/__init__.py,sha256=4yW79U_xvo7vjg6-vRhjtt0bO8MxMSx2PD8dMamS9Q8,633
@@ -671,7 +671,7 @@ supervisely/convert/volume/dicom/dicom_helper.py,sha256=OrKlyt1hA5BOXKhE1LF1WxBI
671
671
  supervisely/convert/volume/nii/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
672
672
  supervisely/convert/volume/nii/nii_planes_volume_converter.py,sha256=dXoBA8AYUOEjLpV2cZJ5n1HDq4_gNhnD__NVsgfc_Qc,14551
673
673
  supervisely/convert/volume/nii/nii_volume_converter.py,sha256=BAOKX96-bp6WfTFLrCQNrXk2YhKqIFSU5LJ-auKiAfc,8514
674
- supervisely/convert/volume/nii/nii_volume_helper.py,sha256=ME_2bgbKZg4IYDFOYqhGRdt7LbwigdF2p6oSgPgPWpw,14132
674
+ supervisely/convert/volume/nii/nii_volume_helper.py,sha256=_FepXNu1RIFuzBOv0aAtrG-J2xN3uyyEMcq6pUD9fsk,14159
675
675
  supervisely/convert/volume/sly/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
676
676
  supervisely/convert/volume/sly/sly_volume_converter.py,sha256=XmSuxnRqxchG87b244f3h0UHvOt6IkajMquL1drWlCM,5595
677
677
  supervisely/convert/volume/sly/sly_volume_helper.py,sha256=gUY0GW3zDMlO2y-zQQG36uoXMrKkKz4-ErM1CDxFCxE,5620
@@ -687,7 +687,7 @@ supervisely/geometry/any_geometry.py,sha256=BOZBsuMYgtkA7cOKp-URjzV9zQkpHuvfp2QP
687
687
  supervisely/geometry/bitmap.py,sha256=-tyIXCfgvNn3c7jHs18aq693JR5xdvhfNf6Vmf4952g,21869
688
688
  supervisely/geometry/bitmap_base.py,sha256=lNamVL3gZ355oYlIEPc0yC84k1bxuCbUVI0ouaZ_Q4k,13814
689
689
  supervisely/geometry/closed_surface_mesh.py,sha256=3ZplCm3Q2bhPcxNmtv2U1UfdezRkC3_BxjwH4yl7wrs,1558
690
- supervisely/geometry/constants.py,sha256=TPYWGcr2GsbgEtKiZj1L_6wpmbaWU9Qjtlwjg136iVg,788
690
+ supervisely/geometry/constants.py,sha256=6lXpwTTFuswuH9WXMy4akHKshQ5C6fgQhdY-XCdVIMA,842
691
691
  supervisely/geometry/conversions.py,sha256=ZY6xWYFWaDA5KDJkcIBBP8LAmMfZwxMeVFfYUYEM6fw,1170
692
692
  supervisely/geometry/cuboid.py,sha256=GVHeUrVgfjUjE3PorV_vtge6_thDvvUYI5-9_HZjfWs,21077
693
693
  supervisely/geometry/cuboid_2d.py,sha256=enQ-7ZVix5SqC7ZEwxgC0Kvmz9J_wXL7NH3m02snNvc,13444
@@ -697,7 +697,7 @@ supervisely/geometry/graph.py,sha256=kSShcGU4kZgwAbvTrqGzC55qha0nI7M5luiMZSbNx_4
697
697
  supervisely/geometry/helpers.py,sha256=2gdYMFWTAr836gVXcp-lkDQs9tdaV0ou33kj3mzJBQA,5132
698
698
  supervisely/geometry/image_rotator.py,sha256=wrU8cXEUfuNcmPms2myUV4BpZqz_2oDArsEUFeiTpxs,6888
699
699
  supervisely/geometry/main_tests.py,sha256=K3Olsz9igHDW2IfIA5JOpjoE8bZ3ex2PXvVR2ZCDrHU,27199
700
- supervisely/geometry/mask_3d.py,sha256=MNvAIALV4vmM3VT4oOJR39mhO0rjBd7QHy1nZK8PbiE,20508
700
+ supervisely/geometry/mask_3d.py,sha256=gaac4wUoG-qmpVcttgfAh2WhS3VUWjcdNqw5V-Aa5GA,26720
701
701
  supervisely/geometry/multichannel_bitmap.py,sha256=dL0igkOCVZiIZ9LDU7srFLA50XGo4doE-B5_E1uboXM,4968
702
702
  supervisely/geometry/point.py,sha256=7ed_Ipd-Ab8ZeqJF5ft0kP9pKVb2iWXCxPuRhMuweMc,13228
703
703
  supervisely/geometry/point_3d.py,sha256=0ico0aV4fuKNBVrysDjUy1Cx1S9CEzBlEVE3AsbVd0E,1669
@@ -1075,13 +1075,13 @@ supervisely/volume/__init__.py,sha256=EBZBY_5mzabXzMUQh5akusIGd16XnX9n8J0jIi_JmW
1075
1075
  supervisely/volume/nrrd_encoder.py,sha256=1lqwwyqxEvctw1ysQ70x4xPSV1uy1g5YcH5CURwL7-c,4084
1076
1076
  supervisely/volume/nrrd_loader.py,sha256=_yqahKcqSRxunHZ5LtnUWIRA7UvIhPKOhAUwYijSGY4,9065
1077
1077
  supervisely/volume/stl_converter.py,sha256=WIMQgHO_u4JT58QdcMXcb_euF1BFhM7D52IVX_0QTxE,6285
1078
- supervisely/volume/volume.py,sha256=ekU8gYhSXrTvWISd_HJT7lwtQ9Uh5t7qgVcFwzJ2NOc,29273
1078
+ supervisely/volume/volume.py,sha256=jDu_p1zPQxCojjtdJlVVTxfuKgVCYmMSY13Xz99k7pA,30765
1079
1079
  supervisely/volume_annotation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
1080
1080
  supervisely/volume_annotation/constants.py,sha256=BdFIh56fy7vzLIjt0gH8xP01EIU-qgQIwbSHVUcABCU,569
1081
1081
  supervisely/volume_annotation/plane.py,sha256=wyezAcc8tLp38O44CwWY0wjdQxf3VjRdFLWooCrk-Nw,16301
1082
1082
  supervisely/volume_annotation/slice.py,sha256=9m3jtUYz4PYKV3rgbeh2ofDebkyg4TomNbkC6BwZ0lA,4635
1083
1083
  supervisely/volume_annotation/volume_annotation.py,sha256=pGu6n8_5JkFpir4HTVRf302gGD2EqJ96Gh4M0_236Qg,32047
1084
- supervisely/volume_annotation/volume_figure.py,sha256=TbwqWml7zELQJkrYxTrlblr8SYsmTjYVz-E-3Zd4oxo,25337
1084
+ supervisely/volume_annotation/volume_figure.py,sha256=3iFyknF8TlLCMBwgg8gJ26wnNTDTRaw8uEJFVkJGh78,25331
1085
1085
  supervisely/volume_annotation/volume_object.py,sha256=rWzOnycoSJ4-CvFgDOP_rPortU4CdcYR26txe5wJHNo,3577
1086
1086
  supervisely/volume_annotation/volume_object_collection.py,sha256=Tc4AovntgoFj5hpTLBv7pCQ3eL0BjorOVpOh2nAE_tA,5706
1087
1087
  supervisely/volume_annotation/volume_tag.py,sha256=MEk1ky7X8zWe2JgV-j8jXt14e8yu2g1kScU26n9lOMk,9494
@@ -1097,9 +1097,9 @@ supervisely/worker_proto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
1097
1097
  supervisely/worker_proto/worker_api_pb2.py,sha256=VQfi5JRBHs2pFCK1snec3JECgGnua3Xjqw_-b3aFxuM,59142
1098
1098
  supervisely/worker_proto/worker_api_pb2_grpc.py,sha256=3BwQXOaP9qpdi0Dt9EKG--Lm8KGN0C5AgmUfRv77_Jk,28940
1099
1099
  supervisely_lib/__init__.py,sha256=7-3QnN8Zf0wj8NCr2oJmqoQWMKKPKTECvjH9pd2S5vY,159
1100
- supervisely-6.73.368.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
1101
- supervisely-6.73.368.dist-info/METADATA,sha256=xfFo1v5HarzQuLuIy2_abk6oYG3SHJcRjiw9wC9qEB4,35154
1102
- supervisely-6.73.368.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
1103
- supervisely-6.73.368.dist-info/entry_points.txt,sha256=U96-5Hxrp2ApRjnCoUiUhWMqijqh8zLR03sEhWtAcms,102
1104
- supervisely-6.73.368.dist-info/top_level.txt,sha256=kcFVwb7SXtfqZifrZaSE3owHExX4gcNYe7Q2uoby084,28
1105
- supervisely-6.73.368.dist-info/RECORD,,
1100
+ supervisely-6.73.369.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
1101
+ supervisely-6.73.369.dist-info/METADATA,sha256=IkZVEQIZSwqfmNiQuR6FoA7bYffMBsM4Ld9bJBWaOSA,35154
1102
+ supervisely-6.73.369.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
1103
+ supervisely-6.73.369.dist-info/entry_points.txt,sha256=U96-5Hxrp2ApRjnCoUiUhWMqijqh8zLR03sEhWtAcms,102
1104
+ supervisely-6.73.369.dist-info/top_level.txt,sha256=kcFVwb7SXtfqZifrZaSE3owHExX4gcNYe7Q2uoby084,28
1105
+ supervisely-6.73.369.dist-info/RECORD,,