supervisely 6.73.438__py3-none-any.whl → 6.73.513__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 (203) hide show
  1. supervisely/__init__.py +137 -1
  2. supervisely/_utils.py +81 -0
  3. supervisely/annotation/annotation.py +8 -2
  4. supervisely/annotation/json_geometries_map.py +14 -11
  5. supervisely/annotation/label.py +80 -3
  6. supervisely/api/annotation_api.py +14 -11
  7. supervisely/api/api.py +59 -38
  8. supervisely/api/app_api.py +11 -2
  9. supervisely/api/dataset_api.py +74 -12
  10. supervisely/api/entities_collection_api.py +10 -0
  11. supervisely/api/entity_annotation/figure_api.py +52 -4
  12. supervisely/api/entity_annotation/object_api.py +3 -3
  13. supervisely/api/entity_annotation/tag_api.py +63 -12
  14. supervisely/api/guides_api.py +210 -0
  15. supervisely/api/image_api.py +72 -1
  16. supervisely/api/labeling_job_api.py +83 -1
  17. supervisely/api/labeling_queue_api.py +33 -7
  18. supervisely/api/module_api.py +9 -0
  19. supervisely/api/project_api.py +71 -26
  20. supervisely/api/storage_api.py +3 -1
  21. supervisely/api/task_api.py +13 -2
  22. supervisely/api/team_api.py +4 -3
  23. supervisely/api/video/video_annotation_api.py +119 -3
  24. supervisely/api/video/video_api.py +65 -14
  25. supervisely/api/video/video_figure_api.py +24 -11
  26. supervisely/app/__init__.py +1 -1
  27. supervisely/app/content.py +23 -7
  28. supervisely/app/development/development.py +18 -2
  29. supervisely/app/fastapi/__init__.py +1 -0
  30. supervisely/app/fastapi/custom_static_files.py +1 -1
  31. supervisely/app/fastapi/multi_user.py +105 -0
  32. supervisely/app/fastapi/subapp.py +88 -42
  33. supervisely/app/fastapi/websocket.py +77 -9
  34. supervisely/app/singleton.py +21 -0
  35. supervisely/app/v1/app_service.py +18 -2
  36. supervisely/app/v1/constants.py +7 -1
  37. supervisely/app/widgets/__init__.py +6 -0
  38. supervisely/app/widgets/activity_feed/__init__.py +0 -0
  39. supervisely/app/widgets/activity_feed/activity_feed.py +239 -0
  40. supervisely/app/widgets/activity_feed/style.css +78 -0
  41. supervisely/app/widgets/activity_feed/template.html +22 -0
  42. supervisely/app/widgets/card/card.py +20 -0
  43. supervisely/app/widgets/classes_list_selector/classes_list_selector.py +121 -9
  44. supervisely/app/widgets/classes_list_selector/template.html +60 -93
  45. supervisely/app/widgets/classes_mapping/classes_mapping.py +13 -12
  46. supervisely/app/widgets/classes_table/classes_table.py +1 -0
  47. supervisely/app/widgets/deploy_model/deploy_model.py +56 -35
  48. supervisely/app/widgets/dialog/dialog.py +12 -0
  49. supervisely/app/widgets/dialog/template.html +2 -1
  50. supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +1 -1
  51. supervisely/app/widgets/experiment_selector/experiment_selector.py +8 -0
  52. supervisely/app/widgets/fast_table/fast_table.py +184 -60
  53. supervisely/app/widgets/fast_table/template.html +1 -1
  54. supervisely/app/widgets/heatmap/__init__.py +0 -0
  55. supervisely/app/widgets/heatmap/heatmap.py +564 -0
  56. supervisely/app/widgets/heatmap/script.js +533 -0
  57. supervisely/app/widgets/heatmap/style.css +233 -0
  58. supervisely/app/widgets/heatmap/template.html +21 -0
  59. supervisely/app/widgets/modal/__init__.py +0 -0
  60. supervisely/app/widgets/modal/modal.py +198 -0
  61. supervisely/app/widgets/modal/template.html +10 -0
  62. supervisely/app/widgets/object_class_view/object_class_view.py +3 -0
  63. supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
  64. supervisely/app/widgets/radio_tabs/template.html +1 -0
  65. supervisely/app/widgets/select/select.py +6 -3
  66. supervisely/app/widgets/select_class/__init__.py +0 -0
  67. supervisely/app/widgets/select_class/select_class.py +363 -0
  68. supervisely/app/widgets/select_class/template.html +50 -0
  69. supervisely/app/widgets/select_cuda/select_cuda.py +22 -0
  70. supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +65 -7
  71. supervisely/app/widgets/select_tag/__init__.py +0 -0
  72. supervisely/app/widgets/select_tag/select_tag.py +352 -0
  73. supervisely/app/widgets/select_tag/template.html +64 -0
  74. supervisely/app/widgets/select_team/select_team.py +37 -4
  75. supervisely/app/widgets/select_team/template.html +4 -5
  76. supervisely/app/widgets/select_user/__init__.py +0 -0
  77. supervisely/app/widgets/select_user/select_user.py +270 -0
  78. supervisely/app/widgets/select_user/template.html +13 -0
  79. supervisely/app/widgets/select_workspace/select_workspace.py +59 -10
  80. supervisely/app/widgets/select_workspace/template.html +9 -12
  81. supervisely/app/widgets/table/table.py +68 -13
  82. supervisely/app/widgets/tree_select/tree_select.py +2 -0
  83. supervisely/aug/aug.py +6 -2
  84. supervisely/convert/base_converter.py +1 -0
  85. supervisely/convert/converter.py +2 -2
  86. supervisely/convert/image/csv/csv_converter.py +24 -15
  87. supervisely/convert/image/image_converter.py +3 -1
  88. supervisely/convert/image/image_helper.py +48 -4
  89. supervisely/convert/image/label_studio/label_studio_converter.py +2 -0
  90. supervisely/convert/image/medical2d/medical2d_helper.py +2 -24
  91. supervisely/convert/image/multispectral/multispectral_converter.py +6 -0
  92. supervisely/convert/image/pascal_voc/pascal_voc_converter.py +8 -5
  93. supervisely/convert/image/pascal_voc/pascal_voc_helper.py +7 -0
  94. supervisely/convert/pointcloud/kitti_3d/kitti_3d_converter.py +33 -3
  95. supervisely/convert/pointcloud/kitti_3d/kitti_3d_helper.py +12 -5
  96. supervisely/convert/pointcloud/las/las_converter.py +13 -1
  97. supervisely/convert/pointcloud/las/las_helper.py +110 -11
  98. supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +27 -16
  99. supervisely/convert/pointcloud/pointcloud_converter.py +91 -3
  100. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +58 -22
  101. supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +21 -47
  102. supervisely/convert/video/__init__.py +1 -0
  103. supervisely/convert/video/multi_view/__init__.py +0 -0
  104. supervisely/convert/video/multi_view/multi_view.py +543 -0
  105. supervisely/convert/video/sly/sly_video_converter.py +359 -3
  106. supervisely/convert/video/video_converter.py +24 -4
  107. supervisely/convert/volume/dicom/dicom_converter.py +13 -5
  108. supervisely/convert/volume/dicom/dicom_helper.py +30 -18
  109. supervisely/geometry/constants.py +1 -0
  110. supervisely/geometry/geometry.py +4 -0
  111. supervisely/geometry/helpers.py +5 -1
  112. supervisely/geometry/oriented_bbox.py +676 -0
  113. supervisely/geometry/polyline_3d.py +110 -0
  114. supervisely/geometry/rectangle.py +2 -1
  115. supervisely/io/env.py +76 -1
  116. supervisely/io/fs.py +21 -0
  117. supervisely/nn/benchmark/base_evaluator.py +104 -11
  118. supervisely/nn/benchmark/instance_segmentation/evaluator.py +1 -8
  119. supervisely/nn/benchmark/object_detection/evaluator.py +20 -4
  120. supervisely/nn/benchmark/object_detection/vis_metrics/pr_curve.py +10 -5
  121. supervisely/nn/benchmark/semantic_segmentation/evaluator.py +34 -16
  122. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/confusion_matrix.py +1 -1
  123. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/frequently_confused.py +1 -1
  124. supervisely/nn/benchmark/semantic_segmentation/vis_metrics/overview.py +1 -1
  125. supervisely/nn/benchmark/visualization/evaluation_result.py +66 -4
  126. supervisely/nn/inference/cache.py +43 -18
  127. supervisely/nn/inference/gui/serving_gui_template.py +5 -2
  128. supervisely/nn/inference/inference.py +916 -222
  129. supervisely/nn/inference/inference_request.py +55 -10
  130. supervisely/nn/inference/predict_app/gui/classes_selector.py +83 -12
  131. supervisely/nn/inference/predict_app/gui/gui.py +676 -488
  132. supervisely/nn/inference/predict_app/gui/input_selector.py +205 -26
  133. supervisely/nn/inference/predict_app/gui/model_selector.py +2 -4
  134. supervisely/nn/inference/predict_app/gui/output_selector.py +46 -6
  135. supervisely/nn/inference/predict_app/gui/settings_selector.py +756 -59
  136. supervisely/nn/inference/predict_app/gui/tags_selector.py +1 -1
  137. supervisely/nn/inference/predict_app/gui/utils.py +236 -119
  138. supervisely/nn/inference/predict_app/predict_app.py +2 -2
  139. supervisely/nn/inference/session.py +43 -35
  140. supervisely/nn/inference/tracking/bbox_tracking.py +118 -35
  141. supervisely/nn/inference/tracking/point_tracking.py +5 -1
  142. supervisely/nn/inference/tracking/tracker_interface.py +10 -1
  143. supervisely/nn/inference/uploader.py +139 -12
  144. supervisely/nn/live_training/__init__.py +7 -0
  145. supervisely/nn/live_training/api_server.py +111 -0
  146. supervisely/nn/live_training/artifacts_utils.py +243 -0
  147. supervisely/nn/live_training/checkpoint_utils.py +229 -0
  148. supervisely/nn/live_training/dynamic_sampler.py +44 -0
  149. supervisely/nn/live_training/helpers.py +14 -0
  150. supervisely/nn/live_training/incremental_dataset.py +146 -0
  151. supervisely/nn/live_training/live_training.py +497 -0
  152. supervisely/nn/live_training/loss_plateau_detector.py +111 -0
  153. supervisely/nn/live_training/request_queue.py +52 -0
  154. supervisely/nn/model/model_api.py +9 -0
  155. supervisely/nn/model/prediction.py +2 -1
  156. supervisely/nn/model/prediction_session.py +26 -14
  157. supervisely/nn/prediction_dto.py +19 -1
  158. supervisely/nn/tracker/base_tracker.py +11 -1
  159. supervisely/nn/tracker/botsort/botsort_config.yaml +0 -1
  160. supervisely/nn/tracker/botsort/tracker/mc_bot_sort.py +7 -4
  161. supervisely/nn/tracker/botsort_tracker.py +94 -65
  162. supervisely/nn/tracker/utils.py +4 -5
  163. supervisely/nn/tracker/visualize.py +93 -93
  164. supervisely/nn/training/gui/classes_selector.py +16 -1
  165. supervisely/nn/training/gui/train_val_splits_selector.py +52 -31
  166. supervisely/nn/training/train_app.py +46 -31
  167. supervisely/project/data_version.py +115 -51
  168. supervisely/project/download.py +1 -1
  169. supervisely/project/pointcloud_episode_project.py +37 -8
  170. supervisely/project/pointcloud_project.py +30 -2
  171. supervisely/project/project.py +14 -2
  172. supervisely/project/project_meta.py +27 -1
  173. supervisely/project/project_settings.py +32 -18
  174. supervisely/project/versioning/__init__.py +1 -0
  175. supervisely/project/versioning/common.py +20 -0
  176. supervisely/project/versioning/schema_fields.py +35 -0
  177. supervisely/project/versioning/video_schema.py +221 -0
  178. supervisely/project/versioning/volume_schema.py +87 -0
  179. supervisely/project/video_project.py +717 -15
  180. supervisely/project/volume_project.py +623 -5
  181. supervisely/template/experiment/experiment.html.jinja +4 -4
  182. supervisely/template/experiment/experiment_generator.py +14 -21
  183. supervisely/template/live_training/__init__.py +0 -0
  184. supervisely/template/live_training/header.html.jinja +96 -0
  185. supervisely/template/live_training/live_training.html.jinja +51 -0
  186. supervisely/template/live_training/live_training_generator.py +464 -0
  187. supervisely/template/live_training/sly-style.css +402 -0
  188. supervisely/template/live_training/template.html.jinja +18 -0
  189. supervisely/versions.json +28 -26
  190. supervisely/video/sampling.py +39 -20
  191. supervisely/video/video.py +41 -12
  192. supervisely/video_annotation/video_figure.py +38 -4
  193. supervisely/video_annotation/video_object.py +29 -4
  194. supervisely/volume/stl_converter.py +2 -0
  195. supervisely/worker_api/agent_rpc.py +24 -1
  196. supervisely/worker_api/rpc_servicer.py +31 -7
  197. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/METADATA +58 -40
  198. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/RECORD +203 -155
  199. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/WHEEL +1 -1
  200. supervisely_lib/__init__.py +6 -1
  201. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/entry_points.txt +0 -0
  202. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info/licenses}/LICENSE +0 -0
  203. {supervisely-6.73.438.dist-info → supervisely-6.73.513.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,22 @@
1
1
  # coding: utf-8
2
+ import io
3
+ import json
2
4
  import os
3
5
  import re
4
- import sys
5
- from collections import namedtuple
6
- from typing import Callable, Dict, List, Optional, Tuple, Union
6
+ import struct
7
+ from collections import defaultdict, namedtuple
8
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
7
9
 
8
10
  import numpy
9
11
  from tqdm import tqdm
10
12
 
13
+ import supervisely as sly
14
+ import supervisely.volume_annotation.constants as volume_constants
11
15
  from supervisely._utils import batched
12
16
  from supervisely.api.api import Api
13
17
  from supervisely.api.module_api import ApiField
18
+ from supervisely.api.project_api import ProjectInfo
19
+ from supervisely.api.volume.volume_api import VolumeInfo
14
20
  from supervisely.collection.key_indexed_collection import KeyIndexedCollection
15
21
  from supervisely.geometry.closed_surface_mesh import ClosedSurfaceMesh
16
22
  from supervisely.geometry.mask_3d import Mask3D
@@ -19,6 +25,11 @@ from supervisely.project.project import OpenMode
19
25
  from supervisely.project.project_meta import ProjectMeta
20
26
  from supervisely.project.project_type import ProjectType
21
27
  from supervisely.project.video_project import VideoDataset, VideoProject
28
+ from supervisely.project.versioning.common import (
29
+ DEFAULT_VOLUME_SCHEMA_VERSION,
30
+ get_volume_snapshot_schema,
31
+ )
32
+ from supervisely.project.versioning.schema_fields import VersionSchemaField
22
33
  from supervisely.sly_logger import logger
23
34
  from supervisely.task.progress import Progress, tqdm_sly
24
35
  from supervisely.video_annotation.key_id_map import KeyIdMap
@@ -111,6 +122,14 @@ class VolumeProject(VideoProject):
111
122
  class DatasetDict(KeyIndexedCollection):
112
123
  item_type = VolumeDataset
113
124
 
125
+ _SERIALIZATION_MAGIC = b"SLYVOLPAR"
126
+ _SERIALIZATION_VERSION = 1
127
+ _SECTION_PROJECT_INFO = 1
128
+ _SECTION_PROJECT_META = 2
129
+ _SECTION_DATASETS = 3
130
+ _SECTION_VOLUMES = 4
131
+ _SECTION_ANNOTATIONS = 5
132
+
114
133
  def get_classes_stats(
115
134
  self,
116
135
  dataset_names: Optional[List[str]] = None,
@@ -202,6 +221,194 @@ class VolumeProject(VideoProject):
202
221
  progress_cb=progress_cb,
203
222
  )
204
223
 
224
+ @staticmethod
225
+ def download_bin(
226
+ api: Api,
227
+ project_id: int,
228
+ dest_dir: Optional[str] = None,
229
+ dataset_ids: Optional[List[int]] = None,
230
+ download_volumes: bool = True,
231
+ log_progress: bool = False,
232
+ progress_cb: Optional[Union[tqdm, Callable]] = None,
233
+ return_bytesio: bool = False,
234
+ schema_version: str = DEFAULT_VOLUME_SCHEMA_VERSION,
235
+ *args,
236
+ **kwargs,
237
+ ) -> Union[str, io.BytesIO]:
238
+ """
239
+ Download a Volume Project snapshot into a Parquet-backed binary blob (`.tar.zst` file or in-memory BytesIO).
240
+
241
+ The snapshot stores:
242
+
243
+ - Project info and meta
244
+ - Dataset tree (dataset infos)
245
+ - Volume infos (optionally)
246
+ - Volume annotations (for the included volumes)
247
+
248
+ The resulting binary snapshot can be restored later with :func:`upload_bin`.
249
+
250
+ :param api: Supervisely API client.
251
+ :type api: :class:`~supervisely.api.api.Api`
252
+ :param project_id: Source Volume Project ID on the server.
253
+ :type project_id: int
254
+ :param dest_dir: Local folder where the snapshot file will be written. Required when `return_bytesio=False`.
255
+ :type dest_dir: str, optional
256
+ :param dataset_ids: Optional list of dataset IDs to include. If provided, only these datasets will be included (recursively, preserving tree structure where applicable).
257
+ :type dataset_ids: List[int], optional
258
+ :param download_volumes: If False, only project/meta/dataset tree is stored (volume infos and annotations are skipped). This is useful for “structure-only” snapshots.
259
+ :type download_volumes: bool, optional
260
+ :param log_progress: If True, show a progress bar (unless a custom ``progress_cb`` is provided).
261
+ :type log_progress: bool
262
+ :param progress_cb: Optional callback (or tqdm-like object) called with incremental progress.
263
+ :type progress_cb: tqdm or callable, optional
264
+ :param return_bytesio: If True, return an in-memory :class:`io.BytesIO` with snapshot bytes. If False, write snapshot to ``dest_dir`` and return the file path.
265
+ :type return_bytesio: bool, optional
266
+ :param schema_version: Snapshot schema version. Controls the internal Parquet layout/fields. Supported values are the keys from :func:`~supervisely.project.volume_schema.get_volume_snapshot_schema` (currently: ``"v2.0.0"``).
267
+ :type schema_version: str, optional
268
+ :return: Snapshot file path (when ``return_bytesio=False``) or a BytesIO (when ``return_bytesio=True``).
269
+ :rtype: str or io.BytesIO
270
+ :raises ValueError: If ``dest_dir`` is not provided and ``return_bytesio`` is False.
271
+ :raises RuntimeError: If required optional dependencies (e.g. pyarrow) are missing.
272
+
273
+ :Usage example:
274
+
275
+ .. code-block:: python
276
+
277
+ import supervisely as sly
278
+ import os
279
+
280
+ api = sly.Api(os.environ["SERVER_ADDRESS"], os.environ["API_TOKEN"])
281
+
282
+ # 1) Save snapshot to disk
283
+ out_path = sly.VolumeProject.download_bin(
284
+ api,
285
+ project_id=123,
286
+ dest_dir="/tmp/vol_project_snapshot",
287
+ download_volumes=True,
288
+ log_progress=True,
289
+ )
290
+
291
+ # 2) Create an in-memory snapshot (BytesIO) and restore it
292
+ blob = sly.VolumeProject.download_bin(
293
+ api,
294
+ project_id=123,
295
+ return_bytesio=True,
296
+ download_volumes=False, # structure-only
297
+ )
298
+ restored = sly.VolumeProject.upload_bin(api, blob, workspace_id=45, project_name="Restored")
299
+ """
300
+
301
+ pa = VolumeProject._require_pyarrow()
302
+ snapshot_schema = get_volume_snapshot_schema(schema_version)
303
+
304
+ if dest_dir is None and not return_bytesio:
305
+ raise ValueError(
306
+ "Local save directory dest_dir must be specified if return_bytesio is False"
307
+ )
308
+
309
+ ds_filters = (
310
+ [{"field": "id", "operator": "in", "value": dataset_ids}]
311
+ if dataset_ids is not None
312
+ else None
313
+ )
314
+
315
+ project_info = api.project.get_info_by_id(project_id)
316
+ project_meta = api.project.get_meta(project_id, with_settings=True)
317
+ project_meta_obj = ProjectMeta.from_json(project_meta)
318
+ dataset_infos = api.dataset.get_list(project_id, filters=ds_filters, recursive=True, include_custom_data=True)
319
+
320
+ dataset_records = [dataset_info._asdict() for dataset_info in dataset_infos]
321
+ volume_records: List[Dict] = []
322
+ annotations: Dict[str, Dict] = {}
323
+ key_id_map = KeyIdMap()
324
+
325
+ for dataset_info in dataset_infos:
326
+ if dataset_ids is not None and dataset_info.id not in dataset_ids:
327
+ continue
328
+
329
+ volumes = api.volume.get_list(dataset_info.id)
330
+ if len(volumes) == 0:
331
+ continue
332
+
333
+ if not download_volumes:
334
+ continue
335
+
336
+ ds_progress = progress_cb
337
+ if log_progress and progress_cb is None:
338
+ ds_progress = tqdm_sly(
339
+ desc="Collecting volumes from: {!r}".format(dataset_info.name),
340
+ total=len(volumes),
341
+ )
342
+
343
+ volume_ids = [volume_info.id for volume_info in volumes]
344
+ ann_jsons = api.volume.annotation.download_bulk(dataset_info.id, volume_ids)
345
+
346
+ # insert custom_data into ann_jsons (api does not return it in download_bulk atm)
347
+ # Build mappings:
348
+ # - volume_id -> ann_json
349
+ # - volume_id -> {figure_id -> spatial_figure_dict}
350
+ ann_by_volume_id: Dict[int, Dict[str, Any]] = {}
351
+ spatial_figures_by_volume: Dict[int, Dict[int, Dict[str, Any]]] = {}
352
+ for ann_json in ann_jsons:
353
+ volume_id = ann_json.get(ApiField.VOLUME_ID)
354
+ if volume_id is None:
355
+ continue
356
+ ann_by_volume_id[volume_id] = ann_json
357
+ figures_list = ann_json.get(volume_constants.SPATIAL_FIGURES, []) or []
358
+ fig_id_to_spatial_figure: Dict[int, Dict[str, Any]] = {}
359
+ for spatial_figure in figures_list:
360
+ fig_id = spatial_figure.get("id")
361
+ if fig_id is not None:
362
+ fig_id_to_spatial_figure[fig_id] = spatial_figure
363
+ spatial_figures_by_volume[volume_id] = fig_id_to_spatial_figure
364
+
365
+ figures_dict = api.volume.figure.download(dataset_info.id, volume_ids)
366
+ for volume_id, figure_infos in figures_dict.items():
367
+ ann_json = ann_by_volume_id.get(volume_id)
368
+ if ann_json is None:
369
+ continue
370
+ fig_id_to_spatial_figure = spatial_figures_by_volume.get(volume_id, {})
371
+ for figure_info in figure_infos:
372
+ spatial_figure = fig_id_to_spatial_figure.get(figure_info.id)
373
+ if spatial_figure is not None:
374
+ spatial_figure[ApiField.CUSTOM_DATA] = figure_info.custom_data
375
+
376
+ for volume_info, ann_json in zip(volumes, ann_jsons):
377
+ ann_dict = snapshot_schema.annotation_dict_from_raw(
378
+ api=api,
379
+ raw_ann_json=ann_json,
380
+ project_meta_obj=project_meta_obj,
381
+ key_id_map=key_id_map,
382
+ )
383
+ volume_records.append(volume_info._asdict())
384
+ annotations[str(volume_info.id)] = ann_dict
385
+ if progress_cb is not None:
386
+ progress_cb(1)
387
+ if ds_progress is not None:
388
+ ds_progress(1)
389
+
390
+ project_info_dict = project_info._asdict()
391
+ project_info_dict[VersionSchemaField.SCHEMA_VERSION] = schema_version
392
+ payload = {
393
+ "project_info": project_info_dict,
394
+ "project_meta": project_meta,
395
+ "dataset_infos": dataset_records,
396
+ "volume_infos": volume_records,
397
+ "annotations": annotations,
398
+ }
399
+ blob = VolumeProject._serialize_payload_to_parquet_blob(pa, payload, snapshot_schema)
400
+
401
+ if return_bytesio:
402
+ stream = io.BytesIO(blob)
403
+ stream.seek(0)
404
+ return stream
405
+
406
+ os.makedirs(dest_dir, exist_ok=True)
407
+ file_path = os.path.join(dest_dir, f"{project_info.id}_{project_info.name}.arrow")
408
+ with open(file_path, "wb") as out:
409
+ out.write(blob)
410
+ return file_path
411
+
205
412
  @staticmethod
206
413
  def upload(
207
414
  directory: str,
@@ -263,6 +470,417 @@ class VolumeProject(VideoProject):
263
470
  progress_cb=progress_cb,
264
471
  )
265
472
 
473
+ @staticmethod
474
+ def upload_bin(
475
+ api: Api,
476
+ file: Union[str, io.BytesIO],
477
+ workspace_id: int,
478
+ project_name: Optional[str] = None,
479
+ log_progress: bool = True,
480
+ progress_cb: Optional[Union[tqdm, Callable]] = None,
481
+ skip_missed_entities: bool = False,
482
+ *args,
483
+ **kwargs,
484
+ ) -> ProjectInfo:
485
+ """
486
+ Restore a volume project from a Parquet blob produced by :func:`download_bin`.
487
+
488
+ :param api: Supervisely API client.
489
+ :type api: :class:`~supervisely.api.api.Api`
490
+ :param file: Snapshot file path (``.tar.zst``) or an in-memory :class:`io.BytesIO` stream.
491
+ :type file: Union[str, io.BytesIO]
492
+ :param workspace_id: Target workspace ID where the project will be created.
493
+ :type workspace_id: int
494
+ :param project_name: Optional new project name. If not provided, the name from the snapshot will be used. If the name already exists in the workspace, a free name will be chosen.
495
+ :type project_name: str, optional
496
+ :param log_progress: If True, show a progress bar (unless a custom ``progress_cb`` is provided).
497
+ :type log_progress: bool
498
+ :param progress_cb: Optional callback (or tqdm-like object) called with incremental progress.
499
+ :type progress_cb: tqdm or callable, optional
500
+ :param skip_missed_entities: If True, skip volumes that cannot be restored because their source hash is missing in the snapshot payload. If False, such cases raise an error.
501
+ :type skip_missed_entities: bool
502
+ :return: Info of the newly created project.
503
+ :rtype: :class:`~supervisely.api.project_api.ProjectInfo`
504
+ :raises RuntimeError: If the snapshot contains volumes without hashes and ``skip_missed_entities`` is False.
505
+ """
506
+
507
+ pa = VolumeProject._require_pyarrow()
508
+
509
+ if isinstance(file, io.BytesIO):
510
+ raw_data = file.getbuffer()
511
+ else:
512
+ with open(file, "rb") as src:
513
+ raw_data = src.read()
514
+
515
+ payload = VolumeProject._deserialize_payload_from_parquet(pa, raw_data)
516
+
517
+ project_meta = ProjectMeta.from_json(payload["project_meta"])
518
+ project_info: Dict = payload.get("project_info", {})
519
+ dataset_records: List[Dict] = payload.get("dataset_infos", [])
520
+ volume_records: List[Dict] = payload.get("volume_infos", [])
521
+ annotations: Dict[str, Dict] = payload.get("annotations", {})
522
+
523
+ project_title = project_name or project_info.get("name")
524
+ if api.project.exists(workspace_id, project_title):
525
+ project_title = api.project.get_free_name(workspace_id, project_title)
526
+ src_project_desc = project_info.get("description")
527
+ new_project_info = api.project.create(
528
+ workspace_id,
529
+ project_title,
530
+ ProjectType.VOLUMES,
531
+ description=src_project_desc,
532
+ readme=project_info.get("readme"),
533
+ )
534
+ api.project.update_meta(new_project_info.id, project_meta)
535
+
536
+ custom_data = new_project_info.custom_data
537
+ source_project_id = payload["project_info"].get("id")
538
+ version_info = payload["project_info"].get("version") or {}
539
+ custom_data["restored_from"] = {
540
+ "project_id": source_project_id,
541
+ "version_num": version_info.get("version"),
542
+ }
543
+ original_custom_data = payload["project_info"].get("custom_data") or {}
544
+ custom_data.update(original_custom_data)
545
+ api.project.update_custom_data(new_project_info.id, custom_data, silent=True)
546
+
547
+ dataset_mapping: Dict[int, sly.DatasetInfo] = {}
548
+ sorted_datasets = sorted(
549
+ dataset_records,
550
+ key=lambda data: (data.get("parent_id") is not None, data.get("parent_id") or 0),
551
+ )
552
+ for dataset_data in sorted_datasets:
553
+ parent_ds_info = dataset_mapping.get(dataset_data.get("parent_id"))
554
+ new_parent_id = parent_ds_info.id if parent_ds_info else None
555
+ new_dataset_info = api.dataset.create(
556
+ project_id=new_project_info.id,
557
+ name=dataset_data.get("name"),
558
+ description=dataset_data.get("description"),
559
+ parent_id=new_parent_id,
560
+ custom_data=dataset_data.get("custom_data"),
561
+ )
562
+ dataset_mapping[dataset_data.get("id")] = new_dataset_info
563
+
564
+ volume_mapping: Dict[int, VolumeInfo] = {}
565
+ volumes_by_dataset: Dict[int, List[Dict]] = defaultdict(list)
566
+ for volume_data in volume_records:
567
+ volumes_by_dataset[volume_data.get("dataset_id")].append(volume_data)
568
+
569
+ for old_dataset_id, dataset_volumes in volumes_by_dataset.items():
570
+ new_dataset_info = dataset_mapping.get(old_dataset_id)
571
+ if new_dataset_info is None:
572
+ continue
573
+
574
+ dataset_volumes_to_upload: List[Dict] = []
575
+ missing_names: List[str] = []
576
+ for vol in dataset_volumes:
577
+ if vol.get("hash"):
578
+ dataset_volumes_to_upload.append(vol)
579
+ else:
580
+ missing_names.append(vol.get("name") or str(vol.get("id")))
581
+
582
+ if missing_names:
583
+ if skip_missed_entities:
584
+ for vol_name in missing_names:
585
+ logger.warning(
586
+ "Volume %r skipped during restoration because its source hash is unavailable.",
587
+ vol_name,
588
+ )
589
+ if len(dataset_volumes_to_upload) == 0:
590
+ continue
591
+ else:
592
+ raise RuntimeError(
593
+ "Cannot restore volumes without available hash. Missing volume names: {}".format(
594
+ ", ".join(missing_names)
595
+ )
596
+ )
597
+
598
+ hashes = [volume.get("hash") for volume in dataset_volumes_to_upload]
599
+ names = [volume.get("name") for volume in dataset_volumes_to_upload]
600
+ metas = [volume.get("meta") for volume in dataset_volumes_to_upload]
601
+
602
+ ds_progress = progress_cb
603
+ if log_progress and progress_cb is None:
604
+ ds_progress = tqdm_sly(
605
+ desc="Uploading volumes to {!r}".format(new_dataset_info.name),
606
+ total=len(dataset_volumes_to_upload),
607
+ )
608
+
609
+ new_volume_infos = api.volume.upload_hashes(
610
+ new_dataset_info.id,
611
+ names=names,
612
+ hashes=hashes,
613
+ metas=metas,
614
+ progress_cb=ds_progress,
615
+ )
616
+
617
+ for old_volume, new_volume in zip(dataset_volumes_to_upload, new_volume_infos):
618
+ volume_mapping[old_volume.get("id")] = new_volume
619
+
620
+ for volume_id_str, ann_json in annotations.items():
621
+ new_volume_info = volume_mapping.get(int(volume_id_str))
622
+ if new_volume_info is None:
623
+ if skip_missed_entities:
624
+ logger.warning(
625
+ "Annotation for volume %s skipped because the source volume was not restored.",
626
+ volume_id_str,
627
+ )
628
+ continue
629
+ ann_json["volumeId"] = new_volume_info.id
630
+ ann = VolumeAnnotation.from_json(ann_json, project_meta, None)
631
+ api.volume.annotation.append(new_volume_info.id, ann, None)
632
+
633
+ return api.project.get_info_by_id(new_project_info.id)
634
+
635
+ @staticmethod
636
+ def _require_pyarrow():
637
+ try:
638
+ import pyarrow as pa # pylint: disable=import-error
639
+ except ModuleNotFoundError as exc:
640
+ raise ModuleNotFoundError(
641
+ "VolumeProject binary versioning requires the optional dependency 'pyarrow'. "
642
+ "Install it with `pip install pyarrow` to use download_bin/upload_bin."
643
+ ) from exc
644
+ return pa
645
+
646
+ @staticmethod
647
+ def _serialize_payload_to_parquet_blob(pa_module, payload: Dict[str, Dict], snapshot_schema) -> bytes:
648
+ dataset_records: List[Dict] = payload.get("dataset_infos", []) or []
649
+ volume_records: List[Dict] = payload.get("volume_infos", []) or []
650
+ annotations_dict: Dict[str, Dict] = payload.get("annotations", {}) or {}
651
+
652
+ dataset_rows = [snapshot_schema.dataset_row_from_record(r) for r in dataset_records]
653
+ dataset_table = pa_module.Table.from_pylist(
654
+ dataset_rows, schema=snapshot_schema.datasets_table_schema(pa_module)
655
+ )
656
+
657
+ volume_rows = [snapshot_schema.volume_row_from_record(r) for r in volume_records]
658
+ volume_table = pa_module.Table.from_pylist(
659
+ volume_rows, schema=snapshot_schema.volumes_table_schema(pa_module)
660
+ )
661
+
662
+ ann_rows = []
663
+ for volume_id_str, ann in annotations_dict.items():
664
+ try:
665
+ src_volume_id = int(volume_id_str)
666
+ except (TypeError, ValueError):
667
+ continue
668
+ ann_rows.append(
669
+ snapshot_schema.annotation_row_from_dict(src_volume_id=src_volume_id, annotation=ann)
670
+ )
671
+ annotations_table = pa_module.Table.from_pylist(
672
+ ann_rows, schema=snapshot_schema.annotations_table_schema(pa_module)
673
+ )
674
+
675
+ sections = [
676
+ (
677
+ VolumeProject._SECTION_PROJECT_INFO,
678
+ VolumeProject._json_bytes(payload.get("project_info", {})),
679
+ ),
680
+ (
681
+ VolumeProject._SECTION_PROJECT_META,
682
+ VolumeProject._json_bytes(payload.get("project_meta", {})),
683
+ ),
684
+ (
685
+ VolumeProject._SECTION_DATASETS,
686
+ VolumeProject._table_to_parquet_bytes(pa_module, dataset_table),
687
+ ),
688
+ (
689
+ VolumeProject._SECTION_VOLUMES,
690
+ VolumeProject._table_to_parquet_bytes(pa_module, volume_table),
691
+ ),
692
+ (
693
+ VolumeProject._SECTION_ANNOTATIONS,
694
+ VolumeProject._table_to_parquet_bytes(pa_module, annotations_table),
695
+ ),
696
+ ]
697
+
698
+ return VolumeProject._assemble_sections(sections)
699
+
700
+ @staticmethod
701
+ def _build_table(pa_module, columns: Dict[str, Tuple[List, Any]]):
702
+ arrays = {}
703
+ for name, (values, dtype) in columns.items():
704
+ arrays[name] = pa_module.array(values, type=dtype)
705
+ return pa_module.table(arrays)
706
+
707
+ @staticmethod
708
+ def _table_to_parquet_bytes(pa_module, table) -> bytes:
709
+ from pyarrow import parquet as pq # pylint: disable=import-error
710
+
711
+ sink = pa_module.BufferOutputStream()
712
+ pq.write_table(table, sink)
713
+ return sink.getvalue().to_pybytes()
714
+
715
+ @staticmethod
716
+ def _parquet_bytes_to_table(pa_module, data: bytes):
717
+ if not data:
718
+ return pa_module.table({})
719
+ from pyarrow import parquet as pq # pylint: disable=import-error
720
+
721
+ buffer = pa_module.BufferReader(data)
722
+ return pq.read_table(buffer)
723
+
724
+ @staticmethod
725
+ def _json_dumps(data) -> str:
726
+ if isinstance(data, str):
727
+ return data
728
+ return json.dumps(data, ensure_ascii=False)
729
+
730
+ @staticmethod
731
+ def _json_bytes(data) -> bytes:
732
+ return VolumeProject._json_dumps(data).encode("utf-8")
733
+
734
+ @staticmethod
735
+ def _assemble_sections(sections: List[Tuple[int, bytes]]) -> bytes:
736
+ if len(sections) > 255:
737
+ raise RuntimeError("Too many sections for VolumeProject binary payload")
738
+ buffer = io.BytesIO()
739
+ buffer.write(VolumeProject._SERIALIZATION_MAGIC)
740
+ buffer.write(struct.pack(">B", VolumeProject._SERIALIZATION_VERSION))
741
+ buffer.write(struct.pack(">B", len(sections)))
742
+ for section_type, payload in sections:
743
+ if payload is None:
744
+ payload = b""
745
+ buffer.write(struct.pack(">B", section_type))
746
+ buffer.write(struct.pack(">Q", len(payload)))
747
+ buffer.write(payload)
748
+ return buffer.getvalue()
749
+
750
+ @staticmethod
751
+ def _parse_parquet_sections(raw_data) -> Dict[int, bytes]:
752
+ magic = VolumeProject._SERIALIZATION_MAGIC
753
+ view = raw_data if isinstance(raw_data, memoryview) else memoryview(raw_data)
754
+ header_len = len(magic) + 2
755
+ if len(view) < header_len:
756
+ logger.warning(
757
+ f"VolumeProject binary payload too small: {len(view)} bytes (need >= {header_len}). First bytes(hex)={view[: min(len(view), 16)].tobytes().hex()}",
758
+ )
759
+ raise RuntimeError("Corrupted VolumeProject binary payload")
760
+ if view[: len(magic)].tobytes() != magic:
761
+ found = view[: len(magic)].tobytes()
762
+ logger.warning(
763
+ f"VolumeProject binary payload magic mismatch. expected={magic.hex()} found={found.hex()} total_bytes={len(view)} prefix16(hex)={view[:16].tobytes().hex()}",
764
+ )
765
+ raise RuntimeError(
766
+ "Unsupported VolumeProject binary payload format (magic mismatch). "
767
+ "Expected magic={!r}, found={!r}".format(magic, found)
768
+ )
769
+
770
+ offset = len(magic)
771
+ version = view[offset]
772
+ offset += 1
773
+ if version != VolumeProject._SERIALIZATION_VERSION:
774
+ logger.warning(
775
+ "VolumeProject binary payload version mismatch. expected=%d found=%d total_bytes=%d",
776
+ VolumeProject._SERIALIZATION_VERSION,
777
+ version,
778
+ len(view),
779
+ )
780
+ raise RuntimeError(
781
+ "Unsupported VolumeProject binary payload version: {}".format(version)
782
+ )
783
+
784
+ section_count = view[offset]
785
+ offset += 1
786
+ sections: Dict[int, bytes] = {}
787
+ for _ in range(section_count):
788
+ if offset + 9 > len(view):
789
+ raise RuntimeError("Corrupted VolumeProject binary payload")
790
+ section_type = view[offset]
791
+ offset += 1
792
+ length = int.from_bytes(view[offset : offset + 8], "big")
793
+ offset += 8
794
+ if offset + length > len(view):
795
+ raise RuntimeError("Corrupted VolumeProject binary payload")
796
+ sections[section_type] = view[offset : offset + length].tobytes()
797
+ offset += length
798
+ return sections
799
+
800
+ @staticmethod
801
+ def _deserialize_payload_from_parquet(pa_module, raw_data) -> Dict:
802
+ sections = VolumeProject._parse_parquet_sections(raw_data)
803
+
804
+ try:
805
+ project_info = json.loads(sections[VolumeProject._SECTION_PROJECT_INFO].decode("utf-8"))
806
+ project_meta = json.loads(sections[VolumeProject._SECTION_PROJECT_META].decode("utf-8"))
807
+ except KeyError as exc:
808
+ raise RuntimeError("VolumeProject payload missing metadata section") from exc
809
+
810
+ if VolumeProject._SECTION_DATASETS not in sections:
811
+ logger.warning("VolumeProject blob has no datasets section; treating as empty.")
812
+ if VolumeProject._SECTION_VOLUMES not in sections:
813
+ logger.warning("VolumeProject blob has no volumes section; treating as empty.")
814
+ if VolumeProject._SECTION_ANNOTATIONS not in sections:
815
+ logger.warning("VolumeProject blob has no annotations section; treating as empty.")
816
+
817
+ dataset_table = VolumeProject._parquet_bytes_to_table(
818
+ pa_module, sections.get(VolumeProject._SECTION_DATASETS, b"")
819
+ )
820
+ volume_table = VolumeProject._parquet_bytes_to_table(
821
+ pa_module, sections.get(VolumeProject._SECTION_VOLUMES, b"")
822
+ )
823
+ annotations_table = VolumeProject._parquet_bytes_to_table(
824
+ pa_module, sections.get(VolumeProject._SECTION_ANNOTATIONS, b"")
825
+ )
826
+
827
+ dataset_records: List[Dict] = []
828
+ if dataset_table is not None and dataset_table.num_rows:
829
+ col_json = (
830
+ VersionSchemaField.JSON
831
+ if VersionSchemaField.JSON in dataset_table.column_names
832
+ else "json"
833
+ )
834
+ if col_json in dataset_table.column_names:
835
+ dataset_jsons = dataset_table.column(col_json).to_pylist()
836
+ dataset_records = [json.loads(item) for item in dataset_jsons]
837
+
838
+ volume_records: List[Dict] = []
839
+ if volume_table is not None and volume_table.num_rows:
840
+ col_json = (
841
+ VersionSchemaField.JSON
842
+ if VersionSchemaField.JSON in volume_table.column_names
843
+ else "json"
844
+ )
845
+ if col_json in volume_table.column_names:
846
+ volume_jsons = volume_table.column(col_json).to_pylist()
847
+ volume_records = [json.loads(item) for item in volume_jsons]
848
+
849
+ annotations: Dict[str, Dict] = {}
850
+ if annotations_table is not None and annotations_table.num_rows:
851
+ col_vol_id = (
852
+ VersionSchemaField.SRC_VOLUME_ID
853
+ if VersionSchemaField.SRC_VOLUME_ID in annotations_table.column_names
854
+ else "volume_id"
855
+ )
856
+ col_ann = (
857
+ VersionSchemaField.ANNOTATION
858
+ if VersionSchemaField.ANNOTATION in annotations_table.column_names
859
+ else "annotation"
860
+ )
861
+ if col_vol_id in annotations_table.column_names and col_ann in annotations_table.column_names:
862
+ annotation_ids = annotations_table.column(col_vol_id).to_pylist()
863
+ annotation_payloads = annotations_table.column(col_ann).to_pylist()
864
+ for volume_id, annotation_json in zip(annotation_ids, annotation_payloads):
865
+ if volume_id is None:
866
+ continue
867
+ annotations[str(volume_id)] = json.loads(annotation_json)
868
+
869
+ return {
870
+ "project_info": project_info,
871
+ "project_meta": project_meta,
872
+ "dataset_infos": dataset_records,
873
+ "volume_infos": volume_records,
874
+ "annotations": annotations,
875
+ }
876
+
877
+ @staticmethod
878
+ def _load_mask_geometries(api: Api, ann: VolumeAnnotation, key_id_map: KeyIdMap) -> None:
879
+ for sf in ann.spatial_figures:
880
+ if sf.geometry.name() != Mask3D.name():
881
+ continue
882
+ api.volume.figure.load_sf_geometry(sf, key_id_map)
883
+
266
884
  @staticmethod
267
885
  def get_train_val_splits_by_count(project_dir: str, train_count: int, val_count: int) -> None:
268
886
  """
@@ -270,7 +888,7 @@ class VolumeProject(VideoProject):
270
888
  :raises: :class:`NotImplementedError` in all cases.
271
889
  """
272
890
  raise NotImplementedError(
273
- f"Static method 'get_train_val_splits_by_count()' is not supported for VolumeProject class now."
891
+ "Static method 'get_train_val_splits_by_count()' is not supported for VolumeProject class now."
274
892
  )
275
893
 
276
894
  @staticmethod
@@ -285,7 +903,7 @@ class VolumeProject(VideoProject):
285
903
  :raises: :class:`NotImplementedError` in all cases.
286
904
  """
287
905
  raise NotImplementedError(
288
- f"Static method 'get_train_val_splits_by_tag()' is not supported for VolumeProject class now."
906
+ "Static method 'get_train_val_splits_by_tag()' is not supported for VolumeProject class now."
289
907
  )
290
908
 
291
909
  @staticmethod