supervisely 6.73.410__py3-none-any.whl → 6.73.470__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.
Potentially problematic release.
This version of supervisely might be problematic. Click here for more details.
- supervisely/__init__.py +136 -1
- supervisely/_utils.py +81 -0
- supervisely/annotation/json_geometries_map.py +2 -0
- supervisely/annotation/label.py +80 -3
- supervisely/api/annotation_api.py +9 -9
- supervisely/api/api.py +67 -43
- supervisely/api/app_api.py +72 -5
- supervisely/api/dataset_api.py +108 -33
- supervisely/api/entity_annotation/figure_api.py +113 -49
- supervisely/api/image_api.py +82 -0
- supervisely/api/module_api.py +10 -0
- supervisely/api/nn/deploy_api.py +15 -9
- supervisely/api/nn/ecosystem_models_api.py +201 -0
- supervisely/api/nn/neural_network_api.py +12 -3
- supervisely/api/pointcloud/pointcloud_api.py +38 -0
- supervisely/api/pointcloud/pointcloud_episode_annotation_api.py +3 -0
- supervisely/api/project_api.py +213 -6
- supervisely/api/task_api.py +11 -1
- supervisely/api/video/video_annotation_api.py +4 -2
- supervisely/api/video/video_api.py +79 -1
- supervisely/api/video/video_figure_api.py +24 -11
- supervisely/api/volume/volume_api.py +38 -0
- supervisely/app/__init__.py +1 -1
- supervisely/app/content.py +14 -6
- supervisely/app/fastapi/__init__.py +1 -0
- supervisely/app/fastapi/custom_static_files.py +1 -1
- supervisely/app/fastapi/multi_user.py +88 -0
- supervisely/app/fastapi/subapp.py +175 -42
- supervisely/app/fastapi/templating.py +1 -1
- supervisely/app/fastapi/websocket.py +77 -9
- supervisely/app/singleton.py +21 -0
- supervisely/app/v1/app_service.py +18 -2
- supervisely/app/v1/constants.py +7 -1
- supervisely/app/widgets/__init__.py +11 -1
- supervisely/app/widgets/agent_selector/template.html +1 -0
- supervisely/app/widgets/card/card.py +20 -0
- supervisely/app/widgets/dataset_thumbnail/dataset_thumbnail.py +11 -2
- supervisely/app/widgets/dataset_thumbnail/template.html +3 -1
- supervisely/app/widgets/deploy_model/deploy_model.py +750 -0
- supervisely/app/widgets/dialog/dialog.py +12 -0
- supervisely/app/widgets/dialog/template.html +2 -1
- supervisely/app/widgets/dropdown_checkbox_selector/__init__.py +0 -0
- supervisely/app/widgets/dropdown_checkbox_selector/dropdown_checkbox_selector.py +87 -0
- supervisely/app/widgets/dropdown_checkbox_selector/template.html +12 -0
- supervisely/app/widgets/ecosystem_model_selector/__init__.py +0 -0
- supervisely/app/widgets/ecosystem_model_selector/ecosystem_model_selector.py +195 -0
- supervisely/app/widgets/experiment_selector/experiment_selector.py +454 -263
- supervisely/app/widgets/fast_table/fast_table.py +713 -126
- supervisely/app/widgets/fast_table/script.js +492 -95
- supervisely/app/widgets/fast_table/style.css +54 -0
- supervisely/app/widgets/fast_table/template.html +45 -5
- supervisely/app/widgets/heatmap/__init__.py +0 -0
- supervisely/app/widgets/heatmap/heatmap.py +523 -0
- supervisely/app/widgets/heatmap/script.js +378 -0
- supervisely/app/widgets/heatmap/style.css +227 -0
- supervisely/app/widgets/heatmap/template.html +21 -0
- supervisely/app/widgets/input_tag/input_tag.py +102 -15
- supervisely/app/widgets/input_tag_list/__init__.py +0 -0
- supervisely/app/widgets/input_tag_list/input_tag_list.py +274 -0
- supervisely/app/widgets/input_tag_list/template.html +70 -0
- supervisely/app/widgets/radio_table/radio_table.py +10 -2
- supervisely/app/widgets/radio_tabs/radio_tabs.py +18 -2
- supervisely/app/widgets/radio_tabs/template.html +1 -0
- supervisely/app/widgets/select/select.py +6 -4
- supervisely/app/widgets/select_dataset/select_dataset.py +6 -0
- supervisely/app/widgets/select_dataset_tree/select_dataset_tree.py +83 -7
- supervisely/app/widgets/table/table.py +68 -13
- supervisely/app/widgets/tabs/tabs.py +22 -6
- supervisely/app/widgets/tabs/template.html +5 -1
- supervisely/app/widgets/transfer/style.css +3 -0
- supervisely/app/widgets/transfer/template.html +3 -1
- supervisely/app/widgets/transfer/transfer.py +48 -45
- supervisely/app/widgets/tree_select/tree_select.py +2 -0
- supervisely/convert/image/csv/csv_converter.py +24 -15
- supervisely/convert/pointcloud/nuscenes_conv/nuscenes_converter.py +43 -41
- supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_converter.py +75 -51
- supervisely/convert/pointcloud_episodes/nuscenes_conv/nuscenes_helper.py +137 -124
- supervisely/convert/video/video_converter.py +2 -2
- supervisely/geometry/polyline_3d.py +110 -0
- supervisely/io/env.py +161 -1
- supervisely/nn/artifacts/__init__.py +1 -1
- supervisely/nn/artifacts/artifacts.py +10 -2
- supervisely/nn/artifacts/detectron2.py +1 -0
- supervisely/nn/artifacts/hrda.py +1 -0
- supervisely/nn/artifacts/mmclassification.py +20 -0
- supervisely/nn/artifacts/mmdetection.py +5 -3
- supervisely/nn/artifacts/mmsegmentation.py +1 -0
- supervisely/nn/artifacts/ritm.py +1 -0
- supervisely/nn/artifacts/rtdetr.py +1 -0
- supervisely/nn/artifacts/unet.py +1 -0
- supervisely/nn/artifacts/utils.py +3 -0
- supervisely/nn/artifacts/yolov5.py +2 -0
- supervisely/nn/artifacts/yolov8.py +1 -0
- supervisely/nn/benchmark/semantic_segmentation/metric_provider.py +18 -18
- supervisely/nn/experiments.py +9 -0
- supervisely/nn/inference/cache.py +37 -17
- supervisely/nn/inference/gui/serving_gui_template.py +39 -13
- supervisely/nn/inference/inference.py +953 -211
- supervisely/nn/inference/inference_request.py +15 -8
- supervisely/nn/inference/instance_segmentation/instance_segmentation.py +1 -0
- supervisely/nn/inference/object_detection/object_detection.py +1 -0
- supervisely/nn/inference/predict_app/__init__.py +0 -0
- supervisely/nn/inference/predict_app/gui/__init__.py +0 -0
- supervisely/nn/inference/predict_app/gui/classes_selector.py +160 -0
- supervisely/nn/inference/predict_app/gui/gui.py +915 -0
- supervisely/nn/inference/predict_app/gui/input_selector.py +344 -0
- supervisely/nn/inference/predict_app/gui/model_selector.py +77 -0
- supervisely/nn/inference/predict_app/gui/output_selector.py +179 -0
- supervisely/nn/inference/predict_app/gui/preview.py +93 -0
- supervisely/nn/inference/predict_app/gui/settings_selector.py +881 -0
- supervisely/nn/inference/predict_app/gui/tags_selector.py +110 -0
- supervisely/nn/inference/predict_app/gui/utils.py +399 -0
- supervisely/nn/inference/predict_app/predict_app.py +176 -0
- supervisely/nn/inference/session.py +47 -39
- supervisely/nn/inference/tracking/bbox_tracking.py +5 -1
- supervisely/nn/inference/tracking/point_tracking.py +5 -1
- supervisely/nn/inference/tracking/tracker_interface.py +4 -0
- supervisely/nn/inference/uploader.py +9 -5
- supervisely/nn/model/model_api.py +44 -22
- supervisely/nn/model/prediction.py +15 -1
- supervisely/nn/model/prediction_session.py +70 -14
- supervisely/nn/prediction_dto.py +7 -0
- supervisely/nn/tracker/__init__.py +6 -8
- supervisely/nn/tracker/base_tracker.py +54 -0
- supervisely/nn/tracker/botsort/__init__.py +1 -0
- supervisely/nn/tracker/botsort/botsort_config.yaml +30 -0
- supervisely/nn/tracker/botsort/osnet_reid/__init__.py +0 -0
- supervisely/nn/tracker/botsort/osnet_reid/osnet.py +566 -0
- supervisely/nn/tracker/botsort/osnet_reid/osnet_reid_interface.py +88 -0
- supervisely/nn/tracker/botsort/tracker/__init__.py +0 -0
- supervisely/nn/tracker/{bot_sort → botsort/tracker}/basetrack.py +1 -2
- supervisely/nn/tracker/{utils → botsort/tracker}/gmc.py +51 -59
- supervisely/nn/tracker/{deep_sort/deep_sort → botsort/tracker}/kalman_filter.py +71 -33
- supervisely/nn/tracker/botsort/tracker/matching.py +202 -0
- supervisely/nn/tracker/{bot_sort/bot_sort.py → botsort/tracker/mc_bot_sort.py} +68 -81
- supervisely/nn/tracker/botsort_tracker.py +273 -0
- supervisely/nn/tracker/calculate_metrics.py +264 -0
- supervisely/nn/tracker/utils.py +273 -0
- supervisely/nn/tracker/visualize.py +520 -0
- supervisely/nn/training/gui/gui.py +152 -49
- supervisely/nn/training/gui/hyperparameters_selector.py +1 -1
- supervisely/nn/training/gui/model_selector.py +8 -6
- supervisely/nn/training/gui/train_val_splits_selector.py +144 -71
- supervisely/nn/training/gui/training_artifacts.py +3 -1
- supervisely/nn/training/train_app.py +225 -46
- supervisely/project/pointcloud_episode_project.py +12 -8
- supervisely/project/pointcloud_project.py +12 -8
- supervisely/project/project.py +221 -75
- supervisely/template/experiment/experiment.html.jinja +105 -55
- supervisely/template/experiment/experiment_generator.py +258 -112
- supervisely/template/experiment/header.html.jinja +31 -13
- supervisely/template/experiment/sly-style.css +7 -2
- supervisely/versions.json +3 -1
- supervisely/video/sampling.py +42 -20
- supervisely/video/video.py +41 -12
- supervisely/video_annotation/video_figure.py +38 -4
- supervisely/volume/stl_converter.py +2 -0
- supervisely/worker_api/agent_rpc.py +24 -1
- supervisely/worker_api/rpc_servicer.py +31 -7
- {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/METADATA +22 -14
- {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/RECORD +167 -148
- supervisely_lib/__init__.py +6 -1
- supervisely/app/widgets/experiment_selector/style.css +0 -27
- supervisely/app/widgets/experiment_selector/template.html +0 -61
- supervisely/nn/tracker/bot_sort/__init__.py +0 -21
- supervisely/nn/tracker/bot_sort/fast_reid_interface.py +0 -152
- supervisely/nn/tracker/bot_sort/matching.py +0 -127
- supervisely/nn/tracker/bot_sort/sly_tracker.py +0 -401
- supervisely/nn/tracker/deep_sort/__init__.py +0 -6
- supervisely/nn/tracker/deep_sort/deep_sort/__init__.py +0 -1
- supervisely/nn/tracker/deep_sort/deep_sort/detection.py +0 -49
- supervisely/nn/tracker/deep_sort/deep_sort/iou_matching.py +0 -81
- supervisely/nn/tracker/deep_sort/deep_sort/linear_assignment.py +0 -202
- supervisely/nn/tracker/deep_sort/deep_sort/nn_matching.py +0 -176
- supervisely/nn/tracker/deep_sort/deep_sort/track.py +0 -166
- supervisely/nn/tracker/deep_sort/deep_sort/tracker.py +0 -145
- supervisely/nn/tracker/deep_sort/deep_sort.py +0 -301
- supervisely/nn/tracker/deep_sort/generate_clip_detections.py +0 -90
- supervisely/nn/tracker/deep_sort/preprocessing.py +0 -70
- supervisely/nn/tracker/deep_sort/sly_tracker.py +0 -273
- supervisely/nn/tracker/tracker.py +0 -285
- supervisely/nn/tracker/utils/kalman_filter.py +0 -492
- supervisely/nn/tracking/__init__.py +0 -1
- supervisely/nn/tracking/boxmot.py +0 -114
- supervisely/nn/tracking/tracking.py +0 -24
- /supervisely/{nn/tracker/utils → app/widgets/deploy_model}/__init__.py +0 -0
- {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/LICENSE +0 -0
- {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/WHEEL +0 -0
- {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/entry_points.txt +0 -0
- {supervisely-6.73.410.dist-info → supervisely-6.73.470.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
|
|
4
|
+
from supervisely.api.api import Api
|
|
5
|
+
from supervisely.api.dataset_api import DatasetInfo
|
|
6
|
+
from supervisely.api.project_api import ProjectInfo
|
|
7
|
+
from supervisely.api.video.video_api import VideoInfo
|
|
8
|
+
from supervisely.app.widgets import (
|
|
9
|
+
Button,
|
|
10
|
+
Card,
|
|
11
|
+
Container,
|
|
12
|
+
FastTable,
|
|
13
|
+
OneOf,
|
|
14
|
+
RadioGroup,
|
|
15
|
+
SelectDatasetTree,
|
|
16
|
+
Text,
|
|
17
|
+
)
|
|
18
|
+
from supervisely.app.widgets.widget import Widget
|
|
19
|
+
from supervisely.project.project import ProjectType
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InputSelector:
|
|
23
|
+
title = "Input data"
|
|
24
|
+
description = "Select input data on which to run model for prediction"
|
|
25
|
+
lock_message = None
|
|
26
|
+
|
|
27
|
+
def __init__(self, workspace_id: int, api: Api):
|
|
28
|
+
# Init Step
|
|
29
|
+
self.workspace_id = workspace_id
|
|
30
|
+
self.api = api
|
|
31
|
+
self.display_widgets: List[Any] = []
|
|
32
|
+
# -------------------------------- #
|
|
33
|
+
|
|
34
|
+
# Init Base Widgets
|
|
35
|
+
self.validator_text = None
|
|
36
|
+
self.button = None
|
|
37
|
+
self.container = None
|
|
38
|
+
self.card = None
|
|
39
|
+
# -------------------------------- #
|
|
40
|
+
|
|
41
|
+
# Init Step Widgets
|
|
42
|
+
# Images
|
|
43
|
+
self.select_dataset_for_images = None
|
|
44
|
+
self.select_image_container = None
|
|
45
|
+
# Videos
|
|
46
|
+
self.select_dataset_for_video = None
|
|
47
|
+
self.select_video = None
|
|
48
|
+
self.select_video_container = None
|
|
49
|
+
# Selector
|
|
50
|
+
self.radio = None
|
|
51
|
+
self.one_of = None
|
|
52
|
+
# -------------------------------- #
|
|
53
|
+
|
|
54
|
+
# Images
|
|
55
|
+
self.select_dataset_for_images = SelectDatasetTree(
|
|
56
|
+
multiselect=True,
|
|
57
|
+
flat=True,
|
|
58
|
+
select_all_datasets=True,
|
|
59
|
+
allowed_project_types=[ProjectType.IMAGES],
|
|
60
|
+
always_open=False,
|
|
61
|
+
compact=False,
|
|
62
|
+
team_is_selectable=False,
|
|
63
|
+
workspace_is_selectable=False,
|
|
64
|
+
show_select_all_datasets_checkbox=True,
|
|
65
|
+
)
|
|
66
|
+
self.select_image_container = Container(widgets=[self.select_dataset_for_images])
|
|
67
|
+
self._radio_item_images = RadioGroup.Item(
|
|
68
|
+
ProjectType.IMAGES.value, "Images", content=self.select_image_container
|
|
69
|
+
)
|
|
70
|
+
# -------------------------------- #
|
|
71
|
+
|
|
72
|
+
# Videos
|
|
73
|
+
self.select_dataset_for_video = SelectDatasetTree(
|
|
74
|
+
multiselect=True,
|
|
75
|
+
flat=True,
|
|
76
|
+
select_all_datasets=True,
|
|
77
|
+
allowed_project_types=[ProjectType.VIDEOS],
|
|
78
|
+
always_open=False,
|
|
79
|
+
compact=False,
|
|
80
|
+
team_is_selectable=False,
|
|
81
|
+
workspace_is_selectable=False,
|
|
82
|
+
show_select_all_datasets_checkbox=True,
|
|
83
|
+
)
|
|
84
|
+
self._video_table_columns = [
|
|
85
|
+
"Video id",
|
|
86
|
+
"Video name",
|
|
87
|
+
"Size",
|
|
88
|
+
"Duration",
|
|
89
|
+
"FPS",
|
|
90
|
+
"Frames count",
|
|
91
|
+
"Dataset name",
|
|
92
|
+
"Dataset id",
|
|
93
|
+
]
|
|
94
|
+
self.select_video = FastTable(
|
|
95
|
+
columns=self._video_table_columns,
|
|
96
|
+
is_selectable=True,
|
|
97
|
+
)
|
|
98
|
+
self.select_video.hide()
|
|
99
|
+
self.select_video_container = Container(
|
|
100
|
+
widgets=[self.select_dataset_for_video, self.select_video]
|
|
101
|
+
)
|
|
102
|
+
self._radio_item_videos = RadioGroup.Item(
|
|
103
|
+
ProjectType.VIDEOS.value, "Videos", content=self.select_video_container
|
|
104
|
+
)
|
|
105
|
+
# -------------------------------- #
|
|
106
|
+
|
|
107
|
+
# Data type Radio Selector
|
|
108
|
+
self.radio = RadioGroup(items=[self._radio_item_images, self._radio_item_videos])
|
|
109
|
+
# self.radio = RadioGroup(items=[self._radio_item_images])
|
|
110
|
+
# self.radio.hide()
|
|
111
|
+
self.one_of = OneOf(conditional_widget=self.radio)
|
|
112
|
+
# Add widgets to display ------------ #
|
|
113
|
+
self.display_widgets.extend([self.radio, self.one_of])
|
|
114
|
+
# ----------------------------------- #
|
|
115
|
+
|
|
116
|
+
# Base Widgets
|
|
117
|
+
self.validator_text = Text("")
|
|
118
|
+
self.validator_text.hide()
|
|
119
|
+
self.button = Button("Select")
|
|
120
|
+
# Add widgets to display ------------ #
|
|
121
|
+
self.display_widgets.extend([self.validator_text, self.button])
|
|
122
|
+
# ----------------------------------- #
|
|
123
|
+
|
|
124
|
+
# Card Layout
|
|
125
|
+
self.container = Container(self.display_widgets)
|
|
126
|
+
self.card = Card(
|
|
127
|
+
title=self.title,
|
|
128
|
+
description=self.description,
|
|
129
|
+
content=self.container,
|
|
130
|
+
lock_message=self.lock_message,
|
|
131
|
+
)
|
|
132
|
+
# ----------------------------------- #
|
|
133
|
+
|
|
134
|
+
self._refresh_table_lock = threading.Lock()
|
|
135
|
+
self._refresh_table_thread: threading.Thread = None
|
|
136
|
+
self._refresh_called = False
|
|
137
|
+
|
|
138
|
+
@self.radio.value_changed
|
|
139
|
+
def input_selector_type_changed(value: str):
|
|
140
|
+
self.validator_text.hide()
|
|
141
|
+
|
|
142
|
+
@self.select_dataset_for_images.project_changed
|
|
143
|
+
def _images_project_changed(project_id):
|
|
144
|
+
self.validator_text.hide()
|
|
145
|
+
|
|
146
|
+
@self.select_dataset_for_images.value_changed
|
|
147
|
+
def _images_dataset_changed(dataset_ids):
|
|
148
|
+
self.validator_text.hide()
|
|
149
|
+
|
|
150
|
+
@self.select_dataset_for_video.project_changed
|
|
151
|
+
def _videos_project_changed(project_id: int):
|
|
152
|
+
self._refresh_video_table_called()
|
|
153
|
+
|
|
154
|
+
@self.select_dataset_for_video.value_changed
|
|
155
|
+
def _videos_dataset_changed(datasets_ids):
|
|
156
|
+
self._refresh_video_table_called()
|
|
157
|
+
|
|
158
|
+
def _refresh_video_table_called(self):
|
|
159
|
+
with self._refresh_table_lock:
|
|
160
|
+
self._refresh_called = True
|
|
161
|
+
if self._refresh_table_thread is None or not self._refresh_table_thread.is_alive():
|
|
162
|
+
self._refresh_table_thread = threading.Thread(target=self._refresh_video_table_loop)
|
|
163
|
+
if self._refresh_table_thread is not None and not self._refresh_table_thread.is_alive():
|
|
164
|
+
self._refresh_table_thread.start()
|
|
165
|
+
|
|
166
|
+
def _refresh_video_table_loop(self):
|
|
167
|
+
while self._refresh_called:
|
|
168
|
+
with self._refresh_table_lock:
|
|
169
|
+
self._refresh_called = False
|
|
170
|
+
self.select_video.loading = True
|
|
171
|
+
self._refresh_video_table()
|
|
172
|
+
if not self._refresh_called:
|
|
173
|
+
self.select_video.loading = False
|
|
174
|
+
|
|
175
|
+
def _refresh_video_table(self):
|
|
176
|
+
self.validator_text.hide()
|
|
177
|
+
self.select_video.clear()
|
|
178
|
+
selected_datasets = self.select_dataset_for_video.get_selected_ids()
|
|
179
|
+
if not selected_datasets:
|
|
180
|
+
self.select_video.hide()
|
|
181
|
+
else:
|
|
182
|
+
rows = []
|
|
183
|
+
self.select_video.show()
|
|
184
|
+
for dataset_id in selected_datasets:
|
|
185
|
+
dataset_info = self.api.dataset.get_info_by_id(dataset_id)
|
|
186
|
+
videos = self.api.video.get_list(dataset_id)
|
|
187
|
+
for video in videos:
|
|
188
|
+
size = f"{video.frame_height}x{video.frame_width}"
|
|
189
|
+
try:
|
|
190
|
+
frame_rate = int(video.frames_count / video.duration)
|
|
191
|
+
except:
|
|
192
|
+
frame_rate = "N/A"
|
|
193
|
+
rows.append(
|
|
194
|
+
[
|
|
195
|
+
video.id,
|
|
196
|
+
video.name,
|
|
197
|
+
size,
|
|
198
|
+
video.duration,
|
|
199
|
+
frame_rate,
|
|
200
|
+
video.frames_count,
|
|
201
|
+
dataset_info.name,
|
|
202
|
+
dataset_info.id,
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
self.select_video.add_rows(rows)
|
|
207
|
+
|
|
208
|
+
def select_project(self, project_id: int, project_info: ProjectInfo = None):
|
|
209
|
+
if project_info is None:
|
|
210
|
+
project_info = self.api.project.get_info_by_id(project_id)
|
|
211
|
+
if project_info.type == ProjectType.IMAGES.value:
|
|
212
|
+
self.select_dataset_for_images.set_project_id(project_id)
|
|
213
|
+
self.select_dataset_for_images.select_all()
|
|
214
|
+
self.radio.set_value(ProjectType.IMAGES.value)
|
|
215
|
+
elif project_info.type == ProjectType.VIDEOS.value:
|
|
216
|
+
self.select_dataset_for_video.set_project_id(project_id)
|
|
217
|
+
self.select_dataset_for_video.select_all()
|
|
218
|
+
self._refresh_video_table()
|
|
219
|
+
self.select_video.select_rows(list(range(len(self.select_video._rows_total))))
|
|
220
|
+
self.radio.set_value(ProjectType.VIDEOS.value)
|
|
221
|
+
else:
|
|
222
|
+
raise ValueError(f"Project of type {project_info.type} is not supported.")
|
|
223
|
+
|
|
224
|
+
def select_datasets(self, dataset_ids: List[int], dataset_infos: List[DatasetInfo] = None):
|
|
225
|
+
if dataset_infos is None:
|
|
226
|
+
dataset_infos = [self.api.dataset.get_info_by_id(ds_id) for ds_id in dataset_ids]
|
|
227
|
+
project_ids = set(ds.project_id for ds in dataset_infos)
|
|
228
|
+
if len(project_ids) > 1:
|
|
229
|
+
raise ValueError("Cannot select datasets from different projects")
|
|
230
|
+
project_id = project_ids.pop()
|
|
231
|
+
project_info = self.api.project.get_info_by_id(project_id)
|
|
232
|
+
if project_info.type == ProjectType.IMAGES.value:
|
|
233
|
+
self.select_dataset_for_images.set_project_id(project_id)
|
|
234
|
+
self.select_dataset_for_images.set_dataset_ids(dataset_ids)
|
|
235
|
+
self.radio.set_value(ProjectType.IMAGES.value)
|
|
236
|
+
elif project_info.type == ProjectType.VIDEOS.value:
|
|
237
|
+
self.select_dataset_for_video.set_project_id(project_id)
|
|
238
|
+
self.select_dataset_for_video.set_dataset_ids(dataset_ids)
|
|
239
|
+
self._refresh_video_table()
|
|
240
|
+
self.select_video.select_rows(list(range(self.select_video._rows_total)))
|
|
241
|
+
self.radio.set_value(ProjectType.VIDEOS.value)
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(f"Project of type {project_info.type} is not supported.")
|
|
244
|
+
|
|
245
|
+
def select_videos(self, video_ids: List[int], video_infos: List[VideoInfo] = None):
|
|
246
|
+
if video_infos is None:
|
|
247
|
+
video_infos = self.api.video.get_info_by_id_batch(video_ids)
|
|
248
|
+
project_id = video_infos[0].project_id
|
|
249
|
+
self.select_dataset_for_video.set_project_id(project_id)
|
|
250
|
+
self.select_dataset_for_video.select_all()
|
|
251
|
+
self._refresh_video_table()
|
|
252
|
+
self.select_video.select_row_by_value("id", video_ids)
|
|
253
|
+
self.radio.set_value(ProjectType.VIDEOS.value)
|
|
254
|
+
|
|
255
|
+
def disable(self):
|
|
256
|
+
for widget in self.widgets_to_disable:
|
|
257
|
+
widget.disable()
|
|
258
|
+
|
|
259
|
+
def enable(self):
|
|
260
|
+
for widget in self.widgets_to_disable:
|
|
261
|
+
widget.enable()
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def widgets_to_disable(self) -> List[Widget]:
|
|
265
|
+
return [
|
|
266
|
+
# Images Selector
|
|
267
|
+
self.select_dataset_for_images,
|
|
268
|
+
self.select_dataset_for_images._select_project,
|
|
269
|
+
self.select_dataset_for_images._select_dataset,
|
|
270
|
+
# Videos Selector
|
|
271
|
+
self.select_dataset_for_video,
|
|
272
|
+
self.select_dataset_for_video._select_project,
|
|
273
|
+
self.select_dataset_for_video._select_dataset,
|
|
274
|
+
self.select_video,
|
|
275
|
+
# Controls
|
|
276
|
+
self.radio,
|
|
277
|
+
self.one_of,
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
def get_settings(self) -> Dict[str, Any]:
|
|
281
|
+
if self.radio.get_value() == ProjectType.IMAGES.value:
|
|
282
|
+
return {
|
|
283
|
+
"project_id": self.select_dataset_for_images.get_selected_project_id(),
|
|
284
|
+
"dataset_ids": self.select_dataset_for_images.get_selected_ids(),
|
|
285
|
+
}
|
|
286
|
+
if self.radio.get_value() == ProjectType.VIDEOS.value:
|
|
287
|
+
rows = self.select_video.get_selected_rows()
|
|
288
|
+
if rows:
|
|
289
|
+
video_ids = [row.row[0] for row in rows]
|
|
290
|
+
else:
|
|
291
|
+
video_ids = None
|
|
292
|
+
return {"video_ids": video_ids}
|
|
293
|
+
|
|
294
|
+
def load_from_json(self, data):
|
|
295
|
+
if "video_ids" in data:
|
|
296
|
+
video_ids = data["video_ids"]
|
|
297
|
+
if not video_ids:
|
|
298
|
+
raise ValueError("Video ids cannot be empty")
|
|
299
|
+
video_infos = self.api.video.get_info_by_id_batch(video_ids)
|
|
300
|
+
if not video_infos:
|
|
301
|
+
raise ValueError(f"Videos with video ids {video_ids} are not found")
|
|
302
|
+
self.select_videos(video_ids, video_infos)
|
|
303
|
+
elif "dataset_ids" in data:
|
|
304
|
+
dataset_ids = data["dataset_ids"]
|
|
305
|
+
self.select_datasets(dataset_ids)
|
|
306
|
+
elif "project_id" in data:
|
|
307
|
+
project_id = data["project_id"]
|
|
308
|
+
self.select_project(project_id)
|
|
309
|
+
|
|
310
|
+
def get_project_id(self) -> int:
|
|
311
|
+
if self.radio.get_value() == ProjectType.IMAGES.value:
|
|
312
|
+
return self.select_dataset_for_images.project_id
|
|
313
|
+
if self.radio.get_value() == ProjectType.VIDEOS.value:
|
|
314
|
+
return self.select_dataset_for_video.project_id
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def validate_step(self) -> bool:
|
|
318
|
+
self.validator_text.hide()
|
|
319
|
+
if self.radio.get_value() == ProjectType.IMAGES.value:
|
|
320
|
+
selected_ids = self.select_dataset_for_images.get_selected_ids()
|
|
321
|
+
if selected_ids is None:
|
|
322
|
+
self.validator_text.set(text="Select a project", status="error")
|
|
323
|
+
self.validator_text.show()
|
|
324
|
+
return False
|
|
325
|
+
if len(selected_ids) == 0:
|
|
326
|
+
self.validator_text.set(text="Select at least one dataset", status="error")
|
|
327
|
+
self.validator_text.show()
|
|
328
|
+
return False
|
|
329
|
+
if self.radio.get_value() == ProjectType.VIDEOS.value:
|
|
330
|
+
if not self.select_dataset_for_video.get_selected_ids():
|
|
331
|
+
self.validator_text.set(text="Select a dataset", status="error")
|
|
332
|
+
self.validator_text.show()
|
|
333
|
+
return False
|
|
334
|
+
if self.select_video._rows_total == 0:
|
|
335
|
+
self.validator_text.set(
|
|
336
|
+
text="No videos found in the selected dataset", status="error"
|
|
337
|
+
)
|
|
338
|
+
self.validator_text.show()
|
|
339
|
+
return False
|
|
340
|
+
if self.select_video.get_selected_rows() == []:
|
|
341
|
+
self.validator_text.set(text="Select a video", status="error")
|
|
342
|
+
self.validator_text.show()
|
|
343
|
+
return False
|
|
344
|
+
return True
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from typing import Any, Dict, List
|
|
2
|
+
|
|
3
|
+
from supervisely.api.api import Api
|
|
4
|
+
from supervisely.app.widgets import Button, Card, Container, DeployModel, Text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelSelector:
|
|
8
|
+
title = "Model"
|
|
9
|
+
description = "Connect to deployed model or deploy new model"
|
|
10
|
+
lock_message = "Select previous step to unlock"
|
|
11
|
+
|
|
12
|
+
def __init__(self, api: Api, team_id: int):
|
|
13
|
+
# Init Step
|
|
14
|
+
self.api = api
|
|
15
|
+
self.team_id = team_id
|
|
16
|
+
self.display_widgets: List[Any] = []
|
|
17
|
+
# -------------------------------- #
|
|
18
|
+
|
|
19
|
+
# Init Base Widgets
|
|
20
|
+
self.validator_text = None
|
|
21
|
+
self.button = None
|
|
22
|
+
self.container = None
|
|
23
|
+
self.card = None
|
|
24
|
+
# -------------------------------- #
|
|
25
|
+
|
|
26
|
+
# Init Step Widgets
|
|
27
|
+
self.model: DeployModel = None
|
|
28
|
+
# -------------------------------- #
|
|
29
|
+
|
|
30
|
+
# Model Selector
|
|
31
|
+
self.model = DeployModel(api=self.api, team_id=self.team_id)
|
|
32
|
+
# Add widgets to display ------------ #
|
|
33
|
+
self.display_widgets.extend([self.model])
|
|
34
|
+
# ----------------------------------- #
|
|
35
|
+
|
|
36
|
+
# Base Widgets
|
|
37
|
+
self.validator_text = Text("")
|
|
38
|
+
self.validator_text.hide()
|
|
39
|
+
# Add widgets to display ------------ #
|
|
40
|
+
self.display_widgets.extend([self.validator_text])
|
|
41
|
+
# ----------------------------------- #
|
|
42
|
+
|
|
43
|
+
# Card Layout
|
|
44
|
+
self.container = Container(self.display_widgets)
|
|
45
|
+
self.card = Card(
|
|
46
|
+
title=self.title,
|
|
47
|
+
description=self.description,
|
|
48
|
+
content=self.container,
|
|
49
|
+
lock_message=self.lock_message,
|
|
50
|
+
)
|
|
51
|
+
# ----------------------------------- #
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def widgets_to_disable(self) -> list:
|
|
55
|
+
return [
|
|
56
|
+
self.model,
|
|
57
|
+
self.model.connect_button,
|
|
58
|
+
self.model.deploy_button,
|
|
59
|
+
self.model.stop_button,
|
|
60
|
+
self.model.disconnect_button,
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def get_settings(self) -> Dict[str, Any]:
|
|
64
|
+
return self.model.get_deploy_parameters()
|
|
65
|
+
|
|
66
|
+
def load_from_json(self, data):
|
|
67
|
+
self.model.load_from_json(data)
|
|
68
|
+
|
|
69
|
+
def validate_step(self) -> bool:
|
|
70
|
+
self.validator_text.hide()
|
|
71
|
+
|
|
72
|
+
if self.model.model_api is None:
|
|
73
|
+
self.validator_text.set(text="Please connect or deploy a model", status="error")
|
|
74
|
+
self.validator_text.show()
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
return True
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from typing import Any, Dict, List
|
|
2
|
+
|
|
3
|
+
from supervisely import logger
|
|
4
|
+
from supervisely.api.api import Api
|
|
5
|
+
from supervisely.app.widgets import (
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
Checkbox,
|
|
9
|
+
Container,
|
|
10
|
+
Field,
|
|
11
|
+
Input,
|
|
12
|
+
OneOf,
|
|
13
|
+
Progress,
|
|
14
|
+
ProjectThumbnail,
|
|
15
|
+
RadioGroup,
|
|
16
|
+
Text,
|
|
17
|
+
)
|
|
18
|
+
from supervisely.project.project_meta import ProjectType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OutputSelector:
|
|
22
|
+
title = "Result"
|
|
23
|
+
description = "Select the output mode"
|
|
24
|
+
lock_message = "Select previous step to unlock"
|
|
25
|
+
|
|
26
|
+
def __init__(self, api: Api):
|
|
27
|
+
# Init Step
|
|
28
|
+
self.api = api
|
|
29
|
+
self.display_widgets: List[Any] = []
|
|
30
|
+
# -------------------------------- #
|
|
31
|
+
|
|
32
|
+
# Init Base Widgets
|
|
33
|
+
self.validator_text = None
|
|
34
|
+
self.start_button = None
|
|
35
|
+
self.stop_button = None
|
|
36
|
+
self.container = None
|
|
37
|
+
self.card = None
|
|
38
|
+
# -------------------------------- #
|
|
39
|
+
|
|
40
|
+
# Init Step Widgets
|
|
41
|
+
self.stop_serving_on_finish = None
|
|
42
|
+
self.stop_self_on_finish = None
|
|
43
|
+
self.project_name_input = None
|
|
44
|
+
self.project_name_field = None
|
|
45
|
+
self.progress = None
|
|
46
|
+
self.project_thumbnail = None
|
|
47
|
+
# -------------------------------- #
|
|
48
|
+
|
|
49
|
+
# TODO: Implement option later
|
|
50
|
+
# Stop Apps on Finish
|
|
51
|
+
# self.stop_serving_on_finish = Checkbox("Stop Serving App on prediction finish", False)
|
|
52
|
+
# self.stop_self_on_finish = Checkbox("Stop Predict App on prediction finish", True)
|
|
53
|
+
# Add widgets to display ------------ #
|
|
54
|
+
# self.display_widgets.extend([self.stop_serving_on_finish, self.stop_self_on_finish])
|
|
55
|
+
# ----------------------------------- #
|
|
56
|
+
|
|
57
|
+
# Project Name
|
|
58
|
+
self.project_name_input = Input(minlength=1, maxlength=255, placeholder="New Project Name")
|
|
59
|
+
self.project_name_field = Field(
|
|
60
|
+
content=self.project_name_input,
|
|
61
|
+
title="New Project Name",
|
|
62
|
+
description="Name of the new project to create for the results. The created project will have the same dataset structure as the input project.",
|
|
63
|
+
)
|
|
64
|
+
self.skip_annotated_checkbox = Checkbox("Skip annotated items", False)
|
|
65
|
+
self._tab_names = ["Create New Project", "Update source project"]
|
|
66
|
+
self._tab_contents = [self.project_name_field, self.skip_annotated_checkbox]
|
|
67
|
+
self.tabs = RadioGroup(
|
|
68
|
+
items=[
|
|
69
|
+
RadioGroup.Item(tab_name, content=tab_content)
|
|
70
|
+
for tab_name, tab_content in zip(self._tab_names, self._tab_contents)
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
self.oneof = OneOf(self.tabs)
|
|
74
|
+
# Add widgets to display ------------ #
|
|
75
|
+
self.display_widgets.extend([self.tabs, self.oneof])
|
|
76
|
+
# ----------------------------------- #
|
|
77
|
+
|
|
78
|
+
# Base Widgets
|
|
79
|
+
self.validator_text = Text("", status="text")
|
|
80
|
+
self.validator_text.hide()
|
|
81
|
+
self.start_button = Button("Run", icon="zmdi zmdi-play")
|
|
82
|
+
self.stop_button = Button("Stop", icon="zmdi zmdi-stop")
|
|
83
|
+
# Add widgets to display ------------ #
|
|
84
|
+
self.display_widgets.extend([self.start_button, self.validator_text])
|
|
85
|
+
# ----------------------------------- #
|
|
86
|
+
|
|
87
|
+
# Progress
|
|
88
|
+
self.progress = Progress(hide_on_finish=False)
|
|
89
|
+
self.progress.hide()
|
|
90
|
+
self.secondary_progress = Progress(hide_on_finish=False)
|
|
91
|
+
self.secondary_progress.hide()
|
|
92
|
+
# Add widgets to display ------------ #
|
|
93
|
+
self.display_widgets.extend([self.progress, self.secondary_progress])
|
|
94
|
+
# ----------------------------------- #
|
|
95
|
+
|
|
96
|
+
# Result
|
|
97
|
+
self.project_thumbnail = ProjectThumbnail()
|
|
98
|
+
self.project_thumbnail.hide()
|
|
99
|
+
# Add widgets to display ------------ #
|
|
100
|
+
self.display_widgets.extend([self.project_thumbnail])
|
|
101
|
+
# ----------------------------------- #
|
|
102
|
+
|
|
103
|
+
# Card Layout
|
|
104
|
+
self.container = Container(self.display_widgets)
|
|
105
|
+
self.card = Card(
|
|
106
|
+
title=self.title,
|
|
107
|
+
description=self.description,
|
|
108
|
+
content=self.container,
|
|
109
|
+
lock_message=self.lock_message,
|
|
110
|
+
)
|
|
111
|
+
# ----------------------------------- #
|
|
112
|
+
|
|
113
|
+
def lock(self):
|
|
114
|
+
self.card.lock(self.lock_message)
|
|
115
|
+
|
|
116
|
+
def unlock(self):
|
|
117
|
+
self.card.unlock()
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def widgets_to_disable(self) -> list:
|
|
121
|
+
return [self.project_name_input]
|
|
122
|
+
|
|
123
|
+
def set_result_thumbnail(self, project_id: int):
|
|
124
|
+
try:
|
|
125
|
+
project_info = self.api.project.get_info_by_id(project_id)
|
|
126
|
+
self.project_thumbnail.set(project_info)
|
|
127
|
+
self.project_thumbnail.show()
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(f"Failed to set result thumbnail: {str(e)}")
|
|
130
|
+
self.project_thumbnail.hide()
|
|
131
|
+
|
|
132
|
+
def get_settings(self) -> Dict[str, Any]:
|
|
133
|
+
settings = {}
|
|
134
|
+
if self.tabs.get_value() == self._tab_names[1]:
|
|
135
|
+
settings["upload_to_source_project"] = True
|
|
136
|
+
else:
|
|
137
|
+
settings["project_name"] = self.project_name_input.get_value()
|
|
138
|
+
settings["skip_annotated"] = self.skip_annotated_checkbox.is_checked()
|
|
139
|
+
return settings
|
|
140
|
+
|
|
141
|
+
def should_stop_serving_on_finish(self) -> bool:
|
|
142
|
+
if self.stop_serving_on_finish is not None:
|
|
143
|
+
return self.stop_serving_on_finish.is_checked()
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
def should_stop_self_on_finish(self) -> bool:
|
|
147
|
+
if self.stop_self_on_finish is not None:
|
|
148
|
+
return self.stop_self_on_finish.is_checked()
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
def load_from_json(self, data):
|
|
152
|
+
project_name = data.get("project_name", None)
|
|
153
|
+
if project_name:
|
|
154
|
+
self.project_name_input.set_value(project_name)
|
|
155
|
+
upload_to_source_project = data.get("upload_to_source_project", False)
|
|
156
|
+
if upload_to_source_project:
|
|
157
|
+
self.tabs.set_value(self._tab_names[1])
|
|
158
|
+
else:
|
|
159
|
+
self.tabs.set_value(self._tab_names[0])
|
|
160
|
+
|
|
161
|
+
def validate_step(self) -> bool:
|
|
162
|
+
self.validator_text.hide()
|
|
163
|
+
if (
|
|
164
|
+
self.tabs.get_value() == self._tab_names[0]
|
|
165
|
+
and self.project_name_input.get_value() == ""
|
|
166
|
+
):
|
|
167
|
+
self.validator_text.set(text="Project name is required", status="error")
|
|
168
|
+
self.validator_text.show()
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
def update_item_type(self, item_type: str):
|
|
174
|
+
if item_type == ProjectType.IMAGES.value:
|
|
175
|
+
self.skip_annotated_checkbox.show()
|
|
176
|
+
elif item_type == ProjectType.VIDEOS.value:
|
|
177
|
+
self.skip_annotated_checkbox.hide()
|
|
178
|
+
else:
|
|
179
|
+
raise ValueError(f"Unsupported item type: {item_type}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import List, Any, Dict
|
|
3
|
+
|
|
4
|
+
from supervisely.api.api import Api
|
|
5
|
+
from supervisely.app.widgets import Button, Container, Card, Text, GridGallery
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Preview:
|
|
9
|
+
title = "Preview"
|
|
10
|
+
description = "Preview the model output"
|
|
11
|
+
lock_message = None
|
|
12
|
+
|
|
13
|
+
def __init__(self, api: Api, static_dir: str):
|
|
14
|
+
# Init Step
|
|
15
|
+
self.api = api
|
|
16
|
+
self.display_widgets: List[Any] = []
|
|
17
|
+
self.static_dir = static_dir
|
|
18
|
+
self.inference_settings = None
|
|
19
|
+
# -------------------------------- #
|
|
20
|
+
|
|
21
|
+
# Init Base Widgets
|
|
22
|
+
self.validator_text = None
|
|
23
|
+
self.button = None
|
|
24
|
+
self.container = None
|
|
25
|
+
self.card = None
|
|
26
|
+
# -------------------------------- #
|
|
27
|
+
|
|
28
|
+
# Init Step Widgets
|
|
29
|
+
self.gallery = None
|
|
30
|
+
# -------------------------------- #
|
|
31
|
+
|
|
32
|
+
# Preview Directory
|
|
33
|
+
self.preview_dir = os.path.join(self.static_dir, "preview")
|
|
34
|
+
os.makedirs(self.preview_dir, exist_ok=True)
|
|
35
|
+
self.preview_path = os.path.join(self.preview_dir, "preview.jpg")
|
|
36
|
+
self.peview_url = f"/static/preview/preview.jpg"
|
|
37
|
+
# ----------------------------------- #
|
|
38
|
+
|
|
39
|
+
# Preview Widget
|
|
40
|
+
self.gallery = GridGallery(
|
|
41
|
+
2,
|
|
42
|
+
sync_views=True,
|
|
43
|
+
enable_zoom=True,
|
|
44
|
+
resize_on_zoom=True,
|
|
45
|
+
empty_message="Click 'Preview' to see the model output.",
|
|
46
|
+
)
|
|
47
|
+
# Add widgets to display ------------ #
|
|
48
|
+
self.display_widgets.extend([self.gallery])
|
|
49
|
+
# ----------------------------------- #
|
|
50
|
+
|
|
51
|
+
# Base Widgets
|
|
52
|
+
self.validator_text = Text("")
|
|
53
|
+
self.validator_text.hide()
|
|
54
|
+
self.button = Button("Preview", icon="zmdi zmdi-eye")
|
|
55
|
+
# Add widgets to display ------------ #
|
|
56
|
+
self.display_widgets.extend([self.validator_text, self.button])
|
|
57
|
+
# ----------------------------------- #
|
|
58
|
+
|
|
59
|
+
# Card Layout
|
|
60
|
+
self.container = Container(self.display_widgets)
|
|
61
|
+
self.card = Card(
|
|
62
|
+
title="Preview",
|
|
63
|
+
content=self.container,
|
|
64
|
+
lock_message=self.lock_message,
|
|
65
|
+
)
|
|
66
|
+
self.card.lock()
|
|
67
|
+
# ----------------------------------- #
|
|
68
|
+
|
|
69
|
+
@self.button.click
|
|
70
|
+
def button_click():
|
|
71
|
+
self.run_preview()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def widgets_to_disable(self) -> list:
|
|
75
|
+
return [self.gallery]
|
|
76
|
+
|
|
77
|
+
def load_from_json(self, data: Dict[str, Any]) -> None:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
def get_settings(self) -> Dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"preview_path": self.preview_path,
|
|
83
|
+
"preview_url": self.peview_url,
|
|
84
|
+
"inference_settings": self.inference_settings,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def validate_step(self) -> bool:
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def run_preview(self) -> None:
|
|
91
|
+
raise NotImplementedError(
|
|
92
|
+
"run_preview must be implemented by subclasses or injected at runtime"
|
|
93
|
+
)
|