labelr 0.1.0__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.
labelr/apps/users.py ADDED
@@ -0,0 +1,36 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from ..config import LABEL_STUDIO_DEFAULT_URL
6
+
7
+ app = typer.Typer()
8
+
9
+ # Label Studio user management
10
+
11
+
12
+ @app.command()
13
+ def list(
14
+ api_key: Annotated[str, typer.Option(envvar="LABEL_STUDIO_API_KEY")],
15
+ label_studio_url: str = LABEL_STUDIO_DEFAULT_URL,
16
+ ):
17
+ """List all users in Label Studio."""
18
+ from label_studio_sdk.client import LabelStudio
19
+
20
+ ls = LabelStudio(base_url=label_studio_url, api_key=api_key)
21
+
22
+ for user in ls.users.list():
23
+ print(f"{user.id:02d}: {user.email}")
24
+
25
+
26
+ @app.command()
27
+ def delete(
28
+ user_id: int,
29
+ api_key: Annotated[str, typer.Option(envvar="LABEL_STUDIO_API_KEY")],
30
+ label_studio_url: str = LABEL_STUDIO_DEFAULT_URL,
31
+ ):
32
+ """Delete a user from Label Studio."""
33
+ from label_studio_sdk.client import LabelStudio
34
+
35
+ ls = LabelStudio(base_url=label_studio_url, api_key=api_key)
36
+ ls.users.delete(user_id)
labelr/check.py ADDED
@@ -0,0 +1,86 @@
1
+ from collections import defaultdict
2
+ from pathlib import Path
3
+
4
+ import imagehash
5
+ import tqdm
6
+ from label_studio_sdk.client import LabelStudio
7
+ from openfoodfacts.utils import get_image_from_url, get_logger
8
+ from PIL import Image
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def check_ls_dataset(ls: LabelStudio, project_id: int):
14
+ skipped = 0
15
+ not_annotated = 0
16
+ annotated = 0
17
+ hash_map = defaultdict(list)
18
+ for task in tqdm.tqdm(
19
+ ls.tasks.list(project=project_id, fields="all"), desc="tasks"
20
+ ):
21
+ annotations = task.annotations
22
+
23
+ if len(annotations) == 0:
24
+ not_annotated += 1
25
+ continue
26
+ elif len(annotations) > 1:
27
+ logger.warning("Task has multiple annotations: %s", task.id)
28
+ continue
29
+
30
+ annotation = annotations[0]
31
+
32
+ if annotation["was_cancelled"]:
33
+ skipped += 1
34
+
35
+ annotated += 1
36
+ image_url = task.data["image_url"]
37
+ image = get_image_from_url(image_url)
38
+ image_hash = str(imagehash.phash(image))
39
+ hash_map[image_hash].append(task.id)
40
+
41
+ for image_hash, task_ids in hash_map.items():
42
+ if len(task_ids) > 1:
43
+ logger.warning("Duplicate images: %s", task_ids)
44
+
45
+ logger.info(
46
+ "Tasks - annotated: %d, skipped: %d, not annotated: %d",
47
+ annotated,
48
+ skipped,
49
+ not_annotated,
50
+ )
51
+
52
+
53
+ def check_local_dataset(dataset_dir: Path, remove: bool = False):
54
+ hash_map = defaultdict(list)
55
+ for path in tqdm.tqdm(dataset_dir.glob("**/*.jpg"), desc="images"):
56
+ if path.is_file() and path.suffix in [
57
+ ".jpg",
58
+ ".jpeg",
59
+ ".png",
60
+ ".webp",
61
+ ".bmp",
62
+ ".tiff",
63
+ ".gif",
64
+ ]:
65
+ image = Image.open(path)
66
+ image_hash = str(imagehash.phash(image))
67
+ logger.debug("Image hash: %s", image_hash)
68
+ hash_map[image_hash].append(path)
69
+
70
+ duplicated = 0
71
+ to_remove = []
72
+ for image_hash, image_paths in hash_map.items():
73
+ if len(image_paths) > 1:
74
+ logger.warning(
75
+ "Duplicate images: %s",
76
+ [str(x.relative_to(dataset_dir)) for x in image_paths],
77
+ )
78
+ duplicated += 1
79
+ to_remove.append(image_paths[0])
80
+
81
+ logger.info("Total duplicated groups: %d", duplicated)
82
+
83
+ if remove and to_remove:
84
+ for path in to_remove:
85
+ logger.info("Removing: %s", str(path))
86
+ path.unlink()
labelr/config.py ADDED
@@ -0,0 +1 @@
1
+ LABEL_STUDIO_DEFAULT_URL = "https://annotate.openfoodfacts.org"
labelr/export.py ADDED
@@ -0,0 +1,270 @@
1
+ import functools
2
+ import logging
3
+ import pickle
4
+ import random
5
+ import tempfile
6
+ import typing
7
+ from pathlib import Path
8
+
9
+ import datasets
10
+ import tqdm
11
+ from label_studio_sdk.client import LabelStudio
12
+ from openfoodfacts.images import download_image
13
+ from PIL import Image
14
+
15
+ from labelr.sample import HF_DS_FEATURES, format_object_detection_sample_to_hf
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _pickle_sample_generator(dir: Path):
21
+ """Generator that yields samples from pickles in a directory."""
22
+ for pkl in dir.glob("*.pkl"):
23
+ with open(pkl, "rb") as f:
24
+ yield pickle.load(f)
25
+
26
+
27
+ def export_from_ls_to_hf(
28
+ ls: LabelStudio,
29
+ repo_id: str,
30
+ category_names: list[str],
31
+ project_id: int,
32
+ ):
33
+ logger.info("Project ID: %d, category names: %s", project_id, category_names)
34
+
35
+ for split in ["train", "val"]:
36
+ logger.info("Processing split: %s", split)
37
+
38
+ with tempfile.TemporaryDirectory() as tmp_dir_str:
39
+ tmp_dir = Path(tmp_dir_str)
40
+ logger.info("Saving samples to temporary directory: %s", tmp_dir)
41
+ for i, task in tqdm.tqdm(
42
+ enumerate(ls.tasks.list(project=project_id, fields="all")),
43
+ desc="tasks",
44
+ ):
45
+ if task.data["split"] != split:
46
+ continue
47
+ sample = format_object_detection_sample_to_hf(
48
+ task.data, task.annotations, category_names
49
+ )
50
+ if sample is not None:
51
+ # Save output as pickle
52
+ with open(tmp_dir / f"{split}_{i:05}.pkl", "wb") as f:
53
+ pickle.dump(sample, f)
54
+
55
+ hf_ds = datasets.Dataset.from_generator(
56
+ functools.partial(_pickle_sample_generator, tmp_dir),
57
+ features=HF_DS_FEATURES,
58
+ )
59
+ hf_ds.push_to_hub(repo_id, split=split)
60
+
61
+
62
+ def export_from_ls_to_ultralytics(
63
+ ls: LabelStudio,
64
+ output_dir: Path,
65
+ category_names: list[str],
66
+ project_id: int,
67
+ train_ratio: float = 0.8,
68
+ error_raise: bool = True,
69
+ ):
70
+ """Export annotations from a Label Studio project to the Ultralytics
71
+ format.
72
+
73
+ The Label Studio project should be an object detection project with a
74
+ single rectanglelabels annotation result per task.
75
+ """
76
+ logger.info("Project ID: %d, category names: %s", project_id, category_names)
77
+
78
+ data_dir = output_dir / "data"
79
+ data_dir.mkdir(parents=True, exist_ok=True)
80
+ split_warning_displayed = False
81
+
82
+ # NOTE: before, all images were sent to val, the last split
83
+ label_dir = data_dir / "labels"
84
+ images_dir = data_dir / "images"
85
+ for split in ["train", "val"]:
86
+ (label_dir / split).mkdir(parents=True, exist_ok=True)
87
+ (images_dir / split).mkdir(parents=True, exist_ok=True)
88
+
89
+ for task in tqdm.tqdm(
90
+ ls.tasks.list(project=project_id, fields="all"),
91
+ desc="tasks",
92
+ ):
93
+ split = task.data.get("split")
94
+
95
+ if split is None:
96
+ if not split_warning_displayed:
97
+ logger.warning(
98
+ "Split information not found, assigning randomly. "
99
+ "To avoid this, set the `split` field in the task data."
100
+ )
101
+ split_warning_displayed = True
102
+ split = "train" if random.random() < train_ratio else "val"
103
+
104
+ elif split not in ["train", "val"]:
105
+ raise ValueError("Invalid split name: %s", split)
106
+
107
+ if len(task.annotations) > 1:
108
+ logger.warning("More than one annotation found, skipping")
109
+ continue
110
+ elif len(task.annotations) == 0:
111
+ logger.debug("No annotation found, skipping")
112
+ continue
113
+
114
+ annotation = task.annotations[0]
115
+ if annotation["was_cancelled"] is True:
116
+ logger.debug("Annotation was cancelled, skipping")
117
+ continue
118
+
119
+ if "image_id" not in task.data:
120
+ raise ValueError(
121
+ "`image_id` field not found in task data. "
122
+ "Make sure the task data contains the `image_id` "
123
+ "field, which should be a unique identifier for the image."
124
+ )
125
+ if "image_url" not in task.data:
126
+ raise ValueError(
127
+ "`image_url` field not found in task data. "
128
+ "Make sure the task data contains the `image_url` "
129
+ "field, which should be the URL of the image."
130
+ )
131
+ image_id = task.data["image_id"]
132
+ image_url = task.data["image_url"]
133
+
134
+ has_valid_annotation = False
135
+ with (label_dir / split / f"{image_id}.txt").open("w") as f:
136
+ if not any(
137
+ annotation_result["type"] == "rectanglelabels"
138
+ for annotation_result in annotation["result"]
139
+ ):
140
+ continue
141
+
142
+ for annotation_result in annotation["result"]:
143
+ if annotation_result["type"] == "rectanglelabels":
144
+ value = annotation_result["value"]
145
+ x_min = value["x"] / 100
146
+ y_min = value["y"] / 100
147
+ width = value["width"] / 100
148
+ height = value["height"] / 100
149
+ category_name = value["rectanglelabels"][0]
150
+ category_id = category_names.index(category_name)
151
+
152
+ # Save the labels in the Ultralytics format:
153
+ # - one label per line
154
+ # - each line is a list of 5 elements:
155
+ # - category_id
156
+ # - x_center
157
+ # - y_center
158
+ # - width
159
+ # - height
160
+ x_center = x_min + width / 2
161
+ y_center = y_min + height / 2
162
+ f.write(f"{category_id} {x_center} {y_center} {width} {height}\n")
163
+ has_valid_annotation = True
164
+
165
+ if has_valid_annotation:
166
+ download_output = download_image(
167
+ image_url, return_bytes=True, error_raise=error_raise
168
+ )
169
+ if download_output is None:
170
+ logger.error("Failed to download image: %s", image_url)
171
+ continue
172
+
173
+ _, image_bytes = typing.cast(tuple[Image.Image, bytes], download_output)
174
+
175
+ with (images_dir / split / f"{image_id}.jpg").open("wb") as f:
176
+ f.write(image_bytes)
177
+
178
+ with (output_dir / "data.yaml").open("w") as f:
179
+ f.write("path: data\n")
180
+ f.write("train: images/train\n")
181
+ f.write("val: images/val\n")
182
+ f.write("test:\n")
183
+ f.write("names:\n")
184
+ for i, category_name in enumerate(category_names):
185
+ f.write(f" {i}: {category_name}\n")
186
+
187
+
188
+ def export_from_hf_to_ultralytics(
189
+ repo_id: str,
190
+ output_dir: Path,
191
+ download_images: bool = True,
192
+ error_raise: bool = True,
193
+ ):
194
+ """Export annotations from a Hugging Face dataset project to the
195
+ Ultralytics format.
196
+
197
+ The Label Studio project should be an object detection project with a
198
+ single rectanglelabels annotation result per task.
199
+ """
200
+ logger.info("Repo ID: %s", repo_id)
201
+ ds = datasets.load_dataset(repo_id)
202
+ data_dir = output_dir / "data"
203
+ data_dir.mkdir(parents=True, exist_ok=True)
204
+ category_id_to_name = {}
205
+
206
+ for split in ["train", "val"]:
207
+ split_labels_dir = data_dir / "labels" / split
208
+ split_labels_dir.mkdir(parents=True, exist_ok=True)
209
+ split_images_dir = data_dir / "images" / split
210
+ split_images_dir.mkdir(parents=True, exist_ok=True)
211
+
212
+ for sample in tqdm.tqdm(ds[split], desc="samples"):
213
+ image_id = sample["image_id"]
214
+ image_url = sample["meta"]["image_url"]
215
+
216
+ if download_images:
217
+ download_output = download_image(
218
+ image_url, return_bytes=True, error_raise=error_raise
219
+ )
220
+ if download_output is None:
221
+ logger.error("Failed to download image: %s", image_url)
222
+ continue
223
+ _, image_bytes = download_output
224
+ with (split_images_dir / f"{image_id}.jpg").open("wb") as f:
225
+ f.write(image_bytes)
226
+ else:
227
+ image = sample["image"]
228
+ image.save(split_images_dir / f"{image_id}.jpg")
229
+
230
+ objects = sample["objects"]
231
+ bboxes = objects["bbox"]
232
+ category_ids = objects["category_id"]
233
+ category_names = objects["category_name"]
234
+
235
+ with (split_labels_dir / f"{image_id}.txt").open("w") as f:
236
+ for bbox, category_id, category_name in zip(
237
+ bboxes, category_ids, category_names
238
+ ):
239
+ if category_id not in category_id_to_name:
240
+ category_id_to_name[category_id] = category_name
241
+ y_min, x_min, y_max, x_max = bbox
242
+ y_min = min(max(y_min, 0.0), 1.0)
243
+ x_min = min(max(x_min, 0.0), 1.0)
244
+ y_max = min(max(y_max, 0.0), 1.0)
245
+ x_max = min(max(x_max, 0.0), 1.0)
246
+ width = x_max - x_min
247
+ height = y_max - y_min
248
+ # Save the labels in the Ultralytics format:
249
+ # - one label per line
250
+ # - each line is a list of 5 elements:
251
+ # - category_id
252
+ # - x_center
253
+ # - y_center
254
+ # - width
255
+ # - height
256
+ x_center = x_min + width / 2
257
+ y_center = y_min + height / 2
258
+ f.write(f"{category_id} {x_center} {y_center} {width} {height}\n")
259
+
260
+ category_names = [
261
+ x[1] for x in sorted(category_id_to_name.items(), key=lambda x: x[0])
262
+ ]
263
+ with (output_dir / "data.yaml").open("w") as f:
264
+ f.write("path: data\n")
265
+ f.write("train: images/train\n")
266
+ f.write("val: images/val\n")
267
+ f.write("test:\n")
268
+ f.write("names:\n")
269
+ for i, category_name in enumerate(category_names):
270
+ f.write(f" {i}: {category_name}\n")
labelr/main.py ADDED
@@ -0,0 +1,269 @@
1
+ from typing import Annotated, Optional
2
+
3
+ import typer
4
+ from openfoodfacts.utils import get_logger
5
+
6
+ from labelr.apps import datasets as dataset_app
7
+ from labelr.apps import projects as project_app
8
+ from labelr.apps import users as user_app
9
+ from labelr.config import LABEL_STUDIO_DEFAULT_URL
10
+
11
+ app = typer.Typer(pretty_exceptions_show_locals=False)
12
+
13
+ logger = get_logger()
14
+
15
+
16
+ @app.command()
17
+ def predict_object(
18
+ model_name: Annotated[
19
+ str, typer.Option(help="Name of the object detection model to run")
20
+ ],
21
+ image_url: Annotated[str, typer.Option(help="URL of the image to process")],
22
+ triton_uri: Annotated[
23
+ str, typer.Option(help="URI (host+port) of the Triton Inference Server")
24
+ ],
25
+ threshold: float = 0.5,
26
+ ):
27
+ from openfoodfacts.utils import get_image_from_url
28
+
29
+ from labelr.triton.object_detection import ObjectDetectionModelRegistry
30
+
31
+ model = ObjectDetectionModelRegistry.get(model_name)
32
+ image = get_image_from_url(image_url)
33
+ output = model.detect_from_image(image, triton_uri=triton_uri)
34
+ results = output.select(threshold=threshold)
35
+
36
+ for result in results:
37
+ typer.echo(result)
38
+
39
+
40
+ # Temporary scripts
41
+
42
+
43
+ @app.command()
44
+ def skip_rotated_images(
45
+ api_key: Annotated[str, typer.Option(envvar="LABEL_STUDIO_API_KEY")],
46
+ project_id: Annotated[int, typer.Option(help="Label Studio project ID")],
47
+ updated_by: Annotated[
48
+ Optional[int], typer.Option(help="User ID to declare as annotator")
49
+ ] = None,
50
+ label_studio_url: str = LABEL_STUDIO_DEFAULT_URL,
51
+ ):
52
+ import requests
53
+ import tqdm
54
+ from label_studio_sdk.client import LabelStudio
55
+ from label_studio_sdk.types.task import Task
56
+ from openfoodfacts.ocr import OCRResult
57
+
58
+ session = requests.Session()
59
+ ls = LabelStudio(base_url=label_studio_url, api_key=api_key)
60
+
61
+ task: Task
62
+ for task in tqdm.tqdm(
63
+ ls.tasks.list(project=project_id, fields="all"), desc="tasks"
64
+ ):
65
+ if any(annotation["was_cancelled"] for annotation in task.annotations):
66
+ continue
67
+
68
+ assert task.total_annotations == 1, (
69
+ "Task has multiple annotations (%s)" % task.id
70
+ )
71
+ task_id = task.id
72
+
73
+ annotation = task.annotations[0]
74
+ annotation_id = annotation["id"]
75
+
76
+ ocr_url = task.data["image_url"].replace(".jpg", ".json")
77
+ ocr_result = OCRResult.from_url(ocr_url, session=session, error_raise=False)
78
+
79
+ if ocr_result is None:
80
+ logger.warning("No OCR result for task: %s", task_id)
81
+ continue
82
+
83
+ orientation_result = ocr_result.get_orientation()
84
+
85
+ if orientation_result is None:
86
+ # logger.info("No orientation for task: %s", task_id)
87
+ continue
88
+
89
+ orientation = orientation_result.orientation.name
90
+ if orientation != "up":
91
+ logger.info(
92
+ "Skipping rotated image for task: %s (orientation: %s)",
93
+ task_id,
94
+ orientation,
95
+ )
96
+ ls.annotations.update(
97
+ id=annotation_id,
98
+ was_cancelled=True,
99
+ updated_by=updated_by,
100
+ )
101
+ elif orientation == "up":
102
+ logger.debug("Keeping annotation for task: %s", task_id)
103
+
104
+
105
+ @app.command()
106
+ def fix_label(
107
+ api_key: Annotated[str, typer.Option(envvar="LABEL_STUDIO_API_KEY")],
108
+ project_id: Annotated[int, typer.Option(help="Label Studio project ID")],
109
+ label_studio_url: str = LABEL_STUDIO_DEFAULT_URL,
110
+ ):
111
+ import tqdm
112
+ from label_studio_sdk.client import LabelStudio
113
+ from label_studio_sdk.types.task import Task
114
+
115
+ ls = LabelStudio(base_url=label_studio_url, api_key=api_key)
116
+
117
+ task: Task
118
+ for task in tqdm.tqdm(
119
+ ls.tasks.list(project=project_id, fields="all"), desc="tasks"
120
+ ):
121
+ for prediction in task.predictions:
122
+ updated = False
123
+ if "result" in prediction:
124
+ for result in prediction["result"]:
125
+ value = result["value"]
126
+ if "rectanglelabels" in value and value["rectanglelabels"] != [
127
+ "price-tag"
128
+ ]:
129
+ value["rectanglelabels"] = ["price-tag"]
130
+ updated = True
131
+
132
+ if updated:
133
+ print(f"Updating prediction {prediction['id']}, task {task.id}")
134
+ ls.predictions.update(prediction["id"], result=prediction["result"])
135
+
136
+ for annotation in task.annotations:
137
+ updated = False
138
+ if "result" in annotation:
139
+ for result in annotation["result"]:
140
+ value = result["value"]
141
+ if "rectanglelabels" in value and value["rectanglelabels"] != [
142
+ "price-tag"
143
+ ]:
144
+ value["rectanglelabels"] = ["price-tag"]
145
+ updated = True
146
+
147
+ if updated:
148
+ print(f"Updating annotation {annotation['id']}, task {task.id}")
149
+ ls.annotations.update(annotation["id"], result=annotation["result"])
150
+
151
+
152
+ @app.command()
153
+ def select_price_tag_images(
154
+ api_key: Annotated[str, typer.Option(envvar="LABEL_STUDIO_API_KEY")],
155
+ project_id: Annotated[int, typer.Option(help="Label Studio project ID")],
156
+ label_studio_url: str = LABEL_STUDIO_DEFAULT_URL,
157
+ ):
158
+ import typing
159
+ from pathlib import Path
160
+ from typing import Any
161
+ from urllib.parse import urlparse
162
+
163
+ import requests
164
+ import tqdm
165
+ from label_studio_sdk.client import LabelStudio
166
+ from label_studio_sdk.types.task import Task
167
+
168
+ session = requests.Session()
169
+ ls = LabelStudio(base_url=label_studio_url, api_key=api_key)
170
+
171
+ proof_paths = (Path(__file__).parent / "proof.txt").read_text().splitlines()
172
+ task: Task
173
+ for task in tqdm.tqdm(
174
+ ls.tasks.list(project=project_id, include="data,id"), desc="tasks"
175
+ ):
176
+ data = typing.cast(dict[str, Any], task.data)
177
+
178
+ if "is_raw_product_shelf" in data:
179
+ continue
180
+ image_url = data["image_url"]
181
+ file_path = urlparse(image_url).path.replace("/img/", "")
182
+ r = session.get(
183
+ f"https://robotoff.openfoodfacts.org/api/v1/images/predict?image_url={image_url}&models=price_proof_classification",
184
+ )
185
+
186
+ if r.status_code != 200:
187
+ print(
188
+ f"Failed to get prediction for {image_url}, error: {r.text} (status: {r.status_code})"
189
+ )
190
+ continue
191
+
192
+ prediction = r.json()["predictions"]["price_proof_classification"][0]["label"]
193
+
194
+ is_raw_preduct_shelf = False
195
+ if prediction in ("PRICE_TAG", "SHELF"):
196
+ is_raw_preduct_shelf = file_path in proof_paths
197
+
198
+ ls.tasks.update(
199
+ task.id,
200
+ data={
201
+ **data,
202
+ "is_raw_product_shelf": "true" if is_raw_preduct_shelf else "false",
203
+ },
204
+ )
205
+
206
+
207
+ @app.command()
208
+ def add_predicted_category(
209
+ api_key: Annotated[str, typer.Option(envvar="LABEL_STUDIO_API_KEY")],
210
+ project_id: Annotated[int, typer.Option(help="Label Studio project ID")],
211
+ label_studio_url: str = LABEL_STUDIO_DEFAULT_URL,
212
+ ):
213
+ import typing
214
+ from typing import Any
215
+
216
+ import requests
217
+ import tqdm
218
+ from label_studio_sdk.client import LabelStudio
219
+ from label_studio_sdk.types.task import Task
220
+
221
+ session = requests.Session()
222
+ ls = LabelStudio(base_url=label_studio_url, api_key=api_key)
223
+
224
+ task: Task
225
+ for task in tqdm.tqdm(
226
+ ls.tasks.list(project=project_id, include="data,id"), desc="tasks"
227
+ ):
228
+ data = typing.cast(dict[str, Any], task.data)
229
+
230
+ if "predicted_category" in data:
231
+ continue
232
+ image_url = data["image_url"]
233
+ r = session.get(
234
+ f"https://robotoff.openfoodfacts.org/api/v1/images/predict?image_url={image_url}&models=price_proof_classification",
235
+ )
236
+
237
+ if r.status_code != 200:
238
+ print(
239
+ f"Failed to get prediction for {image_url}, error: {r.text} (status: {r.status_code})"
240
+ )
241
+ continue
242
+
243
+ predicted_category = r.json()["predictions"]["price_proof_classification"][0][
244
+ "label"
245
+ ]
246
+
247
+ ls.tasks.update(
248
+ task.id,
249
+ data={
250
+ **data,
251
+ "predicted_category": predicted_category,
252
+ },
253
+ )
254
+
255
+
256
+ app.add_typer(user_app.app, name="users", help="Manage Label Studio users")
257
+ app.add_typer(
258
+ project_app.app,
259
+ name="projects",
260
+ help="Manage Label Studio projects (create, import data, etc.)",
261
+ )
262
+ app.add_typer(
263
+ dataset_app.app,
264
+ name="datasets",
265
+ help="Manage datasets (convert, export, check, etc.)",
266
+ )
267
+
268
+ if __name__ == "__main__":
269
+ app()