modelstudio-sdk 0.0.0.dev0__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.
- modelstudio/__init__.py +25 -0
- modelstudio/_http.py +116 -0
- modelstudio/_pandas.py +33 -0
- modelstudio/_polling.py +59 -0
- modelstudio/_version.py +1 -0
- modelstudio/client.py +108 -0
- modelstudio/exceptions.py +80 -0
- modelstudio/models/__init__.py +81 -0
- modelstudio/models/annotations.py +80 -0
- modelstudio/models/categories.py +106 -0
- modelstudio/models/common.py +18 -0
- modelstudio/models/datasets.py +55 -0
- modelstudio/models/deletion.py +29 -0
- modelstudio/models/exports.py +32 -0
- modelstudio/models/few_shot.py +41 -0
- modelstudio/models/filters.py +81 -0
- modelstudio/models/history.py +58 -0
- modelstudio/models/images.py +91 -0
- modelstudio/models/imports.py +141 -0
- modelstudio/models/media.py +37 -0
- modelstudio/models/merge.py +51 -0
- modelstudio/models/metrics.py +180 -0
- modelstudio/models/oversample.py +41 -0
- modelstudio/models/splits.py +112 -0
- modelstudio/models/validation.py +89 -0
- modelstudio/resources/__init__.py +1 -0
- modelstudio/resources/dataset.py +732 -0
- modelstudio/resources/split.py +140 -0
- modelstudio_sdk-0.0.0.dev0.dist-info/METADATA +513 -0
- modelstudio_sdk-0.0.0.dev0.dist-info/RECORD +32 -0
- modelstudio_sdk-0.0.0.dev0.dist-info/WHEEL +4 -0
- modelstudio_sdk-0.0.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"""Dataset resource — top-level collection and per-dataset operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from modelstudio._polling import OperationPoller
|
|
9
|
+
from modelstudio.models.annotations import (
|
|
10
|
+
AnnotationModel,
|
|
11
|
+
CreateAnnotationRequest,
|
|
12
|
+
DeleteAnnotationsResponse,
|
|
13
|
+
UpdateAnnotationRequest,
|
|
14
|
+
)
|
|
15
|
+
from modelstudio.models.categories import (
|
|
16
|
+
CancelSplitCategoryResponse,
|
|
17
|
+
CategoryListModel,
|
|
18
|
+
ConsolidateLabelsResponse,
|
|
19
|
+
MergeBySupercategoryResponse,
|
|
20
|
+
MergeCategoriesResponse,
|
|
21
|
+
MergeSourcesResponse,
|
|
22
|
+
RemoveCategoryResponse,
|
|
23
|
+
RenameCategoryResponse,
|
|
24
|
+
SplitCategoryResponse,
|
|
25
|
+
)
|
|
26
|
+
from modelstudio.models.common import PagedResponse
|
|
27
|
+
from modelstudio.models.datasets import DatasetModel
|
|
28
|
+
from modelstudio.models.deletion import DeletionCheckModel
|
|
29
|
+
from modelstudio.models.exports import ExportCocoModel
|
|
30
|
+
from modelstudio.models.few_shot import FewShotPreviewModel, FewShotRequest, FewShotResponseModel
|
|
31
|
+
from modelstudio.models.filters import DatasetFilterRequest, DatasetFilterResponse
|
|
32
|
+
from modelstudio.models.history import (
|
|
33
|
+
HistoryModel,
|
|
34
|
+
PreviewModel,
|
|
35
|
+
RewindResponse,
|
|
36
|
+
UndoRedoResponse,
|
|
37
|
+
)
|
|
38
|
+
from modelstudio.models.images import (
|
|
39
|
+
MarkSyntheticResponse,
|
|
40
|
+
RemoveEmptyImagesResponse,
|
|
41
|
+
SyntheticStatsModel,
|
|
42
|
+
)
|
|
43
|
+
from modelstudio.models.imports import (
|
|
44
|
+
ImportJobListModel,
|
|
45
|
+
ImportJobModel,
|
|
46
|
+
ImportJobQueuedModel,
|
|
47
|
+
)
|
|
48
|
+
from modelstudio.models.media import MediaJobActionModel, MediaJobStatusModel
|
|
49
|
+
from modelstudio.models.merge import (
|
|
50
|
+
MergeAnalysisModel,
|
|
51
|
+
MergeDatasetRequest,
|
|
52
|
+
MergePreviewModel,
|
|
53
|
+
MergeResponseModel,
|
|
54
|
+
)
|
|
55
|
+
from modelstudio.models.metrics import BinnedModel, ClassDistributionModel, OverviewModel
|
|
56
|
+
from modelstudio.models.oversample import (
|
|
57
|
+
OversampleAnalysisModel,
|
|
58
|
+
OversampleRequest,
|
|
59
|
+
OversampleResponseModel,
|
|
60
|
+
)
|
|
61
|
+
from modelstudio.models.splits import (
|
|
62
|
+
ClassAwareRedistributeResponse,
|
|
63
|
+
LeakageResponse,
|
|
64
|
+
RedistributeResponse,
|
|
65
|
+
SplitModel,
|
|
66
|
+
)
|
|
67
|
+
from modelstudio.models.validation import (
|
|
68
|
+
DuplicateCheckModel,
|
|
69
|
+
ResolveTemporalConflictResponse,
|
|
70
|
+
TemporalConflictModel,
|
|
71
|
+
ValidationResultsModel,
|
|
72
|
+
)
|
|
73
|
+
from modelstudio.resources.split import Split
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING:
|
|
76
|
+
from modelstudio._http import HttpTransport
|
|
77
|
+
|
|
78
|
+
List = list # alias to avoid shadowing by DatasetsCollection.list method
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DatasetsCollection:
|
|
82
|
+
"""Top-level dataset operations (list, create, merge).
|
|
83
|
+
|
|
84
|
+
Access via ``client.datasets``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, transport: HttpTransport) -> None:
|
|
88
|
+
self._t = transport
|
|
89
|
+
|
|
90
|
+
def list(self) -> List[DatasetModel]:
|
|
91
|
+
"""List all datasets."""
|
|
92
|
+
data = self._t.get("/api/v1/datasets")
|
|
93
|
+
return [DatasetModel.model_validate(item) for item in data]
|
|
94
|
+
|
|
95
|
+
def create(
|
|
96
|
+
self,
|
|
97
|
+
name: str,
|
|
98
|
+
dataset_type: str = "object_detection",
|
|
99
|
+
format: str = "coco",
|
|
100
|
+
description: str | None = None,
|
|
101
|
+
) -> DatasetModel:
|
|
102
|
+
"""Create a new dataset."""
|
|
103
|
+
body: dict[str, Any] = {
|
|
104
|
+
"name": name,
|
|
105
|
+
"dataset_type": dataset_type,
|
|
106
|
+
"format": format,
|
|
107
|
+
}
|
|
108
|
+
if description is not None:
|
|
109
|
+
body["description"] = description
|
|
110
|
+
data = self._t.post("/api/v1/datasets", json=body)
|
|
111
|
+
return DatasetModel.model_validate(data)
|
|
112
|
+
|
|
113
|
+
def merge_analyze(self, source_dataset_ids: List[str | UUID]) -> MergeAnalysisModel:
|
|
114
|
+
"""Analyze category conflicts before merging datasets."""
|
|
115
|
+
data = self._t.post(
|
|
116
|
+
"/api/v1/datasets/merge/analyze",
|
|
117
|
+
json={"source_dataset_ids": [str(i) for i in source_dataset_ids]},
|
|
118
|
+
)
|
|
119
|
+
return MergeAnalysisModel.model_validate(data)
|
|
120
|
+
|
|
121
|
+
def merge_preview(self, request: MergeDatasetRequest) -> MergePreviewModel:
|
|
122
|
+
"""Preview a dataset merge."""
|
|
123
|
+
data = self._t.post("/api/v1/datasets/merge/preview", json=request.model_dump())
|
|
124
|
+
return MergePreviewModel.model_validate(data)
|
|
125
|
+
|
|
126
|
+
def merge(self, request: MergeDatasetRequest) -> MergeResponseModel:
|
|
127
|
+
"""Merge multiple datasets into a new one."""
|
|
128
|
+
data = self._t.post("/api/v1/datasets/merge", json=request.model_dump())
|
|
129
|
+
return MergeResponseModel.model_validate(data)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Dataset:
|
|
133
|
+
"""Operations on a specific dataset.
|
|
134
|
+
|
|
135
|
+
Access via ``client.dataset("uuid")``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, transport: HttpTransport, dataset_id: str) -> None:
|
|
139
|
+
self._t = transport
|
|
140
|
+
self._id = dataset_id
|
|
141
|
+
self._base = f"/api/v1/datasets/{dataset_id}"
|
|
142
|
+
|
|
143
|
+
def split(self, split_id: str | UUID) -> Split:
|
|
144
|
+
"""Get a Split sub-resource for per-split operations."""
|
|
145
|
+
return Split(self._t, self._id, str(split_id))
|
|
146
|
+
|
|
147
|
+
# ── Lifecycle ─────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def can_delete(self) -> DeletionCheckModel:
|
|
150
|
+
"""Check if this dataset can be safely deleted."""
|
|
151
|
+
data = self._t.get(f"{self._base}/can-delete")
|
|
152
|
+
return DeletionCheckModel.model_validate(data)
|
|
153
|
+
|
|
154
|
+
def delete(self) -> None:
|
|
155
|
+
"""Delete this dataset."""
|
|
156
|
+
self._t.delete(self._base)
|
|
157
|
+
|
|
158
|
+
def clone(self, name: str, description: str | None = None) -> DatasetModel:
|
|
159
|
+
"""Clone this dataset (async — returns immediately, poll clone_status)."""
|
|
160
|
+
body: dict[str, Any] = {"name": name}
|
|
161
|
+
if description is not None:
|
|
162
|
+
body["description"] = description
|
|
163
|
+
data = self._t.post_accepted(f"{self._base}/clone", json=body)
|
|
164
|
+
return DatasetModel.model_validate(data)
|
|
165
|
+
|
|
166
|
+
def clone_status(self) -> dict[str, Any]:
|
|
167
|
+
"""Get clone operation status."""
|
|
168
|
+
return self._t.get(f"{self._base}/clone/status") # type: ignore[no-any-return]
|
|
169
|
+
|
|
170
|
+
def clone_poller(self, interval: float = 2.0, max_wait: float = 300.0) -> OperationPoller:
|
|
171
|
+
"""Get a poller for the clone operation."""
|
|
172
|
+
return OperationPoller(
|
|
173
|
+
transport=self._t,
|
|
174
|
+
poll_url=f"{self._base}/clone/status",
|
|
175
|
+
terminal_statuses={"COMPLETED", "FAILED", "ERROR"},
|
|
176
|
+
interval=interval,
|
|
177
|
+
max_wait=max_wait,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def lock(self) -> None:
|
|
181
|
+
"""Lock the dataset for finalization."""
|
|
182
|
+
self._t.post(f"{self._base}/finalize/lock")
|
|
183
|
+
|
|
184
|
+
def finalize(self) -> dict[str, Any]:
|
|
185
|
+
"""Start finalization (async)."""
|
|
186
|
+
return self._t.post(f"{self._base}/finalize") # type: ignore[no-any-return]
|
|
187
|
+
|
|
188
|
+
def finalize_status(self) -> dict[str, Any]:
|
|
189
|
+
"""Get finalization status."""
|
|
190
|
+
return self._t.get(f"{self._base}/finalize/status") # type: ignore[no-any-return]
|
|
191
|
+
|
|
192
|
+
def finalize_poller(
|
|
193
|
+
self, interval: float = 3.0, max_wait: float = 600.0
|
|
194
|
+
) -> OperationPoller:
|
|
195
|
+
"""Get a poller for the finalization operation."""
|
|
196
|
+
return OperationPoller(
|
|
197
|
+
transport=self._t,
|
|
198
|
+
poll_url=f"{self._base}/finalize/status",
|
|
199
|
+
terminal_statuses={"COMPLETED", "FAILED", "ERROR"},
|
|
200
|
+
interval=interval,
|
|
201
|
+
max_wait=max_wait,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# ── Splits ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def splits(self) -> list[SplitModel]:
|
|
207
|
+
"""List all splits in this dataset."""
|
|
208
|
+
data = self._t.get(f"{self._base}/splits")
|
|
209
|
+
return [SplitModel.model_validate(item) for item in data]
|
|
210
|
+
|
|
211
|
+
def redistribute(
|
|
212
|
+
self, ratios: dict[str, float], seed: int | None = None
|
|
213
|
+
) -> RedistributeResponse:
|
|
214
|
+
"""Redistribute images across existing splits."""
|
|
215
|
+
body: dict[str, Any] = {"ratios": ratios}
|
|
216
|
+
if seed is not None:
|
|
217
|
+
body["seed"] = seed
|
|
218
|
+
data = self._t.post(f"{self._base}/splits/redistribute", json=body)
|
|
219
|
+
return RedistributeResponse.model_validate(data)
|
|
220
|
+
|
|
221
|
+
def class_aware_redistribute(
|
|
222
|
+
self,
|
|
223
|
+
ratios: dict[str, float],
|
|
224
|
+
seed: int | None = None,
|
|
225
|
+
mode: str | None = None,
|
|
226
|
+
prevent_tile_leakage: bool | None = None,
|
|
227
|
+
max_swap_iterations: int | None = None,
|
|
228
|
+
error_metric: str | None = None,
|
|
229
|
+
) -> ClassAwareRedistributeResponse:
|
|
230
|
+
"""Class-aware redistribution that balances class distributions across splits."""
|
|
231
|
+
body: dict[str, Any] = {"ratios": ratios}
|
|
232
|
+
if seed is not None:
|
|
233
|
+
body["seed"] = seed
|
|
234
|
+
if mode is not None:
|
|
235
|
+
body["mode"] = mode
|
|
236
|
+
if prevent_tile_leakage is not None:
|
|
237
|
+
body["prevent_tile_leakage"] = prevent_tile_leakage
|
|
238
|
+
if max_swap_iterations is not None:
|
|
239
|
+
body["max_swap_iterations"] = max_swap_iterations
|
|
240
|
+
if error_metric is not None:
|
|
241
|
+
body["error_metric"] = error_metric
|
|
242
|
+
data = self._t.post(f"{self._base}/splits/class-aware-redistribute", json=body)
|
|
243
|
+
return ClassAwareRedistributeResponse.model_validate(data)
|
|
244
|
+
|
|
245
|
+
def check_leakage(self, method: str = "image_hash") -> LeakageResponse:
|
|
246
|
+
"""Check for data leakage between splits."""
|
|
247
|
+
data = self._t.post(f"{self._base}/splits/check-leakage", json={"method": method})
|
|
248
|
+
return LeakageResponse.model_validate(data)
|
|
249
|
+
|
|
250
|
+
# ── Import Jobs ───────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def list_import_jobs(
|
|
253
|
+
self, status: str | None = None, include_log: bool = False
|
|
254
|
+
) -> ImportJobListModel:
|
|
255
|
+
"""List import jobs for this dataset."""
|
|
256
|
+
params: dict[str, Any] = {}
|
|
257
|
+
if status is not None:
|
|
258
|
+
params["status"] = status
|
|
259
|
+
if include_log:
|
|
260
|
+
params["includeLog"] = "true"
|
|
261
|
+
data = self._t.get(f"{self._base}/imports", params=params or None)
|
|
262
|
+
return ImportJobListModel.model_validate(data)
|
|
263
|
+
|
|
264
|
+
def get_import_job(self, job_id: str | UUID) -> ImportJobModel:
|
|
265
|
+
"""Get details for a specific import job."""
|
|
266
|
+
data = self._t.get(f"{self._base}/imports/{job_id}")
|
|
267
|
+
return ImportJobModel.model_validate(data)
|
|
268
|
+
|
|
269
|
+
def cancel_import_job(self, job_id: str | UUID) -> ImportJobModel:
|
|
270
|
+
"""Cancel a running import job."""
|
|
271
|
+
data = self._t.post(f"{self._base}/imports/{job_id}/cancel")
|
|
272
|
+
return ImportJobModel.model_validate(data)
|
|
273
|
+
|
|
274
|
+
def retry_import_job(self, job_id: str | UUID) -> ImportJobQueuedModel:
|
|
275
|
+
"""Retry a failed import job."""
|
|
276
|
+
data = self._t.post_accepted(f"{self._base}/imports/{job_id}/retry")
|
|
277
|
+
return ImportJobQueuedModel.model_validate(data)
|
|
278
|
+
|
|
279
|
+
# ── Media Jobs ────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def media_status(self) -> MediaJobStatusModel:
|
|
282
|
+
"""Get aggregate media processing status for this dataset."""
|
|
283
|
+
data = self._t.get(f"{self._base}/media/status")
|
|
284
|
+
return MediaJobStatusModel.model_validate(data)
|
|
285
|
+
|
|
286
|
+
def retry_media_job(self, job_id: str | UUID) -> MediaJobActionModel:
|
|
287
|
+
"""Retry a single failed media job."""
|
|
288
|
+
data = self._t.post(f"{self._base}/media/jobs/{job_id}/retry")
|
|
289
|
+
return MediaJobActionModel.model_validate(data)
|
|
290
|
+
|
|
291
|
+
def cancel_media_job(self, job_id: str | UUID) -> MediaJobActionModel:
|
|
292
|
+
"""Cancel a single media job."""
|
|
293
|
+
data = self._t.post(f"{self._base}/media/jobs/{job_id}/cancel")
|
|
294
|
+
return MediaJobActionModel.model_validate(data)
|
|
295
|
+
|
|
296
|
+
def retry_all_media_jobs(self) -> MediaJobActionModel:
|
|
297
|
+
"""Retry all failed media jobs."""
|
|
298
|
+
data = self._t.post(f"{self._base}/media/jobs/retry-all")
|
|
299
|
+
return MediaJobActionModel.model_validate(data)
|
|
300
|
+
|
|
301
|
+
def cancel_queued_media_jobs(self) -> MediaJobActionModel:
|
|
302
|
+
"""Cancel all queued media jobs."""
|
|
303
|
+
data = self._t.post(f"{self._base}/media/jobs/cancel-queued")
|
|
304
|
+
return MediaJobActionModel.model_validate(data)
|
|
305
|
+
|
|
306
|
+
# ── Categories ────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def categories(self, include_empty: bool = True) -> CategoryListModel:
|
|
309
|
+
"""List all categories (dataset-wide)."""
|
|
310
|
+
params: dict[str, Any] | None = None
|
|
311
|
+
if not include_empty:
|
|
312
|
+
params = {"includeEmpty": "false"}
|
|
313
|
+
data = self._t.get(f"{self._base}/categories", params=params)
|
|
314
|
+
return CategoryListModel.model_validate(data)
|
|
315
|
+
|
|
316
|
+
def merge_categories(
|
|
317
|
+
self,
|
|
318
|
+
source_categories: list[int],
|
|
319
|
+
target_category: str,
|
|
320
|
+
target_supercategory: str | None = None,
|
|
321
|
+
) -> MergeCategoriesResponse:
|
|
322
|
+
"""Merge multiple categories into one."""
|
|
323
|
+
body: dict[str, Any] = {
|
|
324
|
+
"source_categories": source_categories,
|
|
325
|
+
"target_category": target_category,
|
|
326
|
+
}
|
|
327
|
+
if target_supercategory is not None:
|
|
328
|
+
body["target_supercategory"] = target_supercategory
|
|
329
|
+
data = self._t.post(f"{self._base}/categories/merge", json=body)
|
|
330
|
+
return MergeCategoriesResponse.model_validate(data)
|
|
331
|
+
|
|
332
|
+
def consolidate_labels(self, mapping: dict[str, str]) -> ConsolidateLabelsResponse:
|
|
333
|
+
"""Apply a label consolidation mapping."""
|
|
334
|
+
data = self._t.post(f"{self._base}/categories/consolidate", json={"mapping": mapping})
|
|
335
|
+
return ConsolidateLabelsResponse.model_validate(data)
|
|
336
|
+
|
|
337
|
+
def rename_category(
|
|
338
|
+
self,
|
|
339
|
+
category_id: int,
|
|
340
|
+
new_name: str,
|
|
341
|
+
new_supercategory: str | None = None,
|
|
342
|
+
) -> RenameCategoryResponse:
|
|
343
|
+
"""Rename a category."""
|
|
344
|
+
body: dict[str, Any] = {"new_name": new_name}
|
|
345
|
+
if new_supercategory is not None:
|
|
346
|
+
body["new_supercategory"] = new_supercategory
|
|
347
|
+
data = self._t.put(f"{self._base}/categories/{category_id}/rename", json=body)
|
|
348
|
+
return RenameCategoryResponse.model_validate(data)
|
|
349
|
+
|
|
350
|
+
def remove_category(
|
|
351
|
+
self,
|
|
352
|
+
category_id: int,
|
|
353
|
+
action: str = "delete_annotations",
|
|
354
|
+
reassign_to: int | None = None,
|
|
355
|
+
) -> RemoveCategoryResponse:
|
|
356
|
+
"""Remove a category and its annotations.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
category_id: The category to remove.
|
|
360
|
+
action: What to do with annotations — "delete_annotations" or "reassign".
|
|
361
|
+
reassign_to: Target category ID when action is "reassign".
|
|
362
|
+
"""
|
|
363
|
+
params: dict[str, Any] = {"action": action}
|
|
364
|
+
if reassign_to is not None:
|
|
365
|
+
params["reassignTo"] = str(reassign_to)
|
|
366
|
+
data = self._t.delete(f"{self._base}/categories/{category_id}", params=params)
|
|
367
|
+
return RemoveCategoryResponse.model_validate(data)
|
|
368
|
+
|
|
369
|
+
def split_category(
|
|
370
|
+
self,
|
|
371
|
+
source_category_id: int,
|
|
372
|
+
target_categories: list[str],
|
|
373
|
+
supercategory: str | None = None,
|
|
374
|
+
) -> SplitCategoryResponse:
|
|
375
|
+
"""Split a category into multiple new categories."""
|
|
376
|
+
body: dict[str, Any] = {
|
|
377
|
+
"source_category_id": source_category_id,
|
|
378
|
+
"target_categories": target_categories,
|
|
379
|
+
}
|
|
380
|
+
if supercategory is not None:
|
|
381
|
+
body["supercategory"] = supercategory
|
|
382
|
+
data = self._t.post(f"{self._base}/categories/split", json=body)
|
|
383
|
+
return SplitCategoryResponse.model_validate(data)
|
|
384
|
+
|
|
385
|
+
def assign_split_category(
|
|
386
|
+
self, operation_id: str, assignments: dict[str, str]
|
|
387
|
+
) -> Any:
|
|
388
|
+
"""Assign annotations to new categories during a split operation."""
|
|
389
|
+
return self._t.post(
|
|
390
|
+
f"{self._base}/categories/split/assign",
|
|
391
|
+
json={"operation_id": operation_id, "assignments": assignments},
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def cancel_split_category(self, operation_id: str) -> CancelSplitCategoryResponse:
|
|
395
|
+
"""Cancel a category split operation."""
|
|
396
|
+
data = self._t.delete(f"{self._base}/categories/split/{operation_id}")
|
|
397
|
+
return CancelSplitCategoryResponse.model_validate(data)
|
|
398
|
+
|
|
399
|
+
def category_annotations(
|
|
400
|
+
self, category_id: int, limit: int = 100, offset: int = 0
|
|
401
|
+
) -> list[dict[str, Any]]:
|
|
402
|
+
"""Get annotations for a specific category."""
|
|
403
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
404
|
+
return self._t.get( # type: ignore[no-any-return]
|
|
405
|
+
f"{self._base}/categories/{category_id}/annotations", params=params
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def category_merge_sources(self, category_id: int) -> MergeSourcesResponse:
|
|
409
|
+
"""Get merge history for a category."""
|
|
410
|
+
data = self._t.get(f"{self._base}/categories/{category_id}/merge-sources")
|
|
411
|
+
return MergeSourcesResponse.model_validate(data)
|
|
412
|
+
|
|
413
|
+
def merge_by_supercategory(
|
|
414
|
+
self, supercategory: str, target_category_name: str
|
|
415
|
+
) -> MergeBySupercategoryResponse:
|
|
416
|
+
"""Merge all categories with a given supercategory."""
|
|
417
|
+
data = self._t.post(
|
|
418
|
+
f"{self._base}/categories/merge-by-supercategory",
|
|
419
|
+
json={
|
|
420
|
+
"supercategory": supercategory,
|
|
421
|
+
"target_category_name": target_category_name,
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
return MergeBySupercategoryResponse.model_validate(data)
|
|
425
|
+
|
|
426
|
+
# ── Images ────────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
def list_images(
|
|
429
|
+
self,
|
|
430
|
+
split_id: str | UUID | None = None,
|
|
431
|
+
page: int = 0,
|
|
432
|
+
size: int = 50,
|
|
433
|
+
sort_by: str | None = None,
|
|
434
|
+
sort_dir: str | None = None,
|
|
435
|
+
search: str | None = None,
|
|
436
|
+
include_facets: bool = False,
|
|
437
|
+
**filters: Any,
|
|
438
|
+
) -> PagedResponse:
|
|
439
|
+
"""List images in this dataset with pagination.
|
|
440
|
+
|
|
441
|
+
Returns a ``PagedResponse`` whose ``content`` contains image dicts.
|
|
442
|
+
Use ``images_df()`` for a DataFrame convenience wrapper.
|
|
443
|
+
"""
|
|
444
|
+
params: dict[str, Any] = {"page": page, "size": size}
|
|
445
|
+
if split_id is not None:
|
|
446
|
+
params["splitId"] = str(split_id)
|
|
447
|
+
if sort_by is not None:
|
|
448
|
+
params["sortBy"] = sort_by
|
|
449
|
+
if sort_dir is not None:
|
|
450
|
+
params["sortDir"] = sort_dir
|
|
451
|
+
if search is not None:
|
|
452
|
+
params["search"] = search
|
|
453
|
+
if include_facets:
|
|
454
|
+
params["includeFacets"] = "true"
|
|
455
|
+
params.update(filters)
|
|
456
|
+
data = self._t.get(f"{self._base}/images", params=params)
|
|
457
|
+
return PagedResponse.model_validate(data)
|
|
458
|
+
|
|
459
|
+
def remove_empty_images(
|
|
460
|
+
self, split_id: str | UUID | None = None, dry_run: bool = False
|
|
461
|
+
) -> RemoveEmptyImagesResponse:
|
|
462
|
+
"""Remove images with no annotations."""
|
|
463
|
+
body: dict[str, Any] = {"dry_run": dry_run}
|
|
464
|
+
if split_id is not None:
|
|
465
|
+
body["split_id"] = str(split_id)
|
|
466
|
+
data = self._t.delete_with_body(f"{self._base}/images/empty", json=body)
|
|
467
|
+
return RemoveEmptyImagesResponse.model_validate(data)
|
|
468
|
+
|
|
469
|
+
def mark_synthetic(
|
|
470
|
+
self,
|
|
471
|
+
mode: str = "ALL",
|
|
472
|
+
mark_as_synthetic: bool = True,
|
|
473
|
+
image_ids: list[str | UUID] | None = None,
|
|
474
|
+
filename_pattern: str | None = None,
|
|
475
|
+
) -> MarkSyntheticResponse:
|
|
476
|
+
"""Mark images as synthetic or real."""
|
|
477
|
+
body: dict[str, Any] = {"mode": mode, "mark_as_synthetic": mark_as_synthetic}
|
|
478
|
+
if image_ids is not None:
|
|
479
|
+
body["image_ids"] = [str(i) for i in image_ids]
|
|
480
|
+
if filename_pattern is not None:
|
|
481
|
+
body["filename_pattern"] = filename_pattern
|
|
482
|
+
data = self._t.put(f"{self._base}/images/synthetic", json=body)
|
|
483
|
+
return MarkSyntheticResponse.model_validate(data)
|
|
484
|
+
|
|
485
|
+
def synthetic_stats(self) -> SyntheticStatsModel:
|
|
486
|
+
"""Get synthetic vs real image statistics."""
|
|
487
|
+
data = self._t.get(f"{self._base}/images/synthetic/stats")
|
|
488
|
+
return SyntheticStatsModel.model_validate(data)
|
|
489
|
+
|
|
490
|
+
# ── Annotations ───────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
def list_annotations(
|
|
493
|
+
self,
|
|
494
|
+
split_id: str | UUID | None = None,
|
|
495
|
+
image_id: str | UUID | None = None,
|
|
496
|
+
page: int = 0,
|
|
497
|
+
size: int = 50,
|
|
498
|
+
sort_by: str | None = None,
|
|
499
|
+
sort_dir: str | None = None,
|
|
500
|
+
**filters: Any,
|
|
501
|
+
) -> PagedResponse:
|
|
502
|
+
"""List annotations in this dataset with pagination.
|
|
503
|
+
|
|
504
|
+
Returns a ``PagedResponse`` whose ``content`` contains annotation dicts.
|
|
505
|
+
Use ``annotations_df()`` for a DataFrame convenience wrapper.
|
|
506
|
+
"""
|
|
507
|
+
params: dict[str, Any] = {"page": page, "size": size}
|
|
508
|
+
if split_id is not None:
|
|
509
|
+
params["splitId"] = str(split_id)
|
|
510
|
+
if image_id is not None:
|
|
511
|
+
params["imageId"] = str(image_id)
|
|
512
|
+
if sort_by is not None:
|
|
513
|
+
params["sortBy"] = sort_by
|
|
514
|
+
if sort_dir is not None:
|
|
515
|
+
params["sortDir"] = sort_dir
|
|
516
|
+
params.update(filters)
|
|
517
|
+
data = self._t.get(f"{self._base}/annotations", params=params)
|
|
518
|
+
return PagedResponse.model_validate(data)
|
|
519
|
+
|
|
520
|
+
def create_annotation(self, request: CreateAnnotationRequest) -> AnnotationModel:
|
|
521
|
+
"""Create a single annotation."""
|
|
522
|
+
data = self._t.post(f"{self._base}/annotations", json=request.model_dump())
|
|
523
|
+
return AnnotationModel.model_validate(data)
|
|
524
|
+
|
|
525
|
+
def update_annotation(
|
|
526
|
+
self, annotation_id: int, request: UpdateAnnotationRequest
|
|
527
|
+
) -> AnnotationModel:
|
|
528
|
+
"""Update an annotation."""
|
|
529
|
+
data = self._t.put(
|
|
530
|
+
f"{self._base}/annotations/{annotation_id}",
|
|
531
|
+
json=request.model_dump(exclude_none=True),
|
|
532
|
+
)
|
|
533
|
+
return AnnotationModel.model_validate(data)
|
|
534
|
+
|
|
535
|
+
def delete_annotation(self, annotation_id: int) -> None:
|
|
536
|
+
"""Delete a single annotation."""
|
|
537
|
+
self._t.delete(f"{self._base}/annotations/{annotation_id}")
|
|
538
|
+
|
|
539
|
+
def delete_annotations(
|
|
540
|
+
self,
|
|
541
|
+
annotation_ids: list[int] | None = None,
|
|
542
|
+
**kwargs: Any,
|
|
543
|
+
) -> DeleteAnnotationsResponse:
|
|
544
|
+
"""Delete annotations from this dataset."""
|
|
545
|
+
body: dict[str, Any] = {}
|
|
546
|
+
if annotation_ids is not None:
|
|
547
|
+
body["annotation_ids"] = annotation_ids
|
|
548
|
+
body.update(kwargs)
|
|
549
|
+
data = self._t.delete_with_body(f"{self._base}/annotations", json=body)
|
|
550
|
+
return DeleteAnnotationsResponse.model_validate(data)
|
|
551
|
+
|
|
552
|
+
def ingest_annotations(self, coco_json: dict[str, Any]) -> Any:
|
|
553
|
+
"""Bulk ingest COCO annotations at dataset level."""
|
|
554
|
+
return self._t.post(f"{self._base}/annotations/ingest", json=coco_json)
|
|
555
|
+
|
|
556
|
+
# ── Validation / QA ───────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
def validate(self) -> ValidationResultsModel:
|
|
559
|
+
"""Run validation checks on this dataset."""
|
|
560
|
+
data = self._t.post(f"{self._base}/validate")
|
|
561
|
+
return ValidationResultsModel.model_validate(data)
|
|
562
|
+
|
|
563
|
+
def check_duplicates(
|
|
564
|
+
self, check_type: str = "image_hashes", hash_threshold: float = 0.95
|
|
565
|
+
) -> DuplicateCheckModel:
|
|
566
|
+
"""Check for duplicate images."""
|
|
567
|
+
data = self._t.post(
|
|
568
|
+
f"{self._base}/validate/duplicates",
|
|
569
|
+
json={"check_type": check_type, "hash_threshold": hash_threshold},
|
|
570
|
+
)
|
|
571
|
+
return DuplicateCheckModel.model_validate(data)
|
|
572
|
+
|
|
573
|
+
def detect_temporal_conflicts(self) -> TemporalConflictModel:
|
|
574
|
+
"""Detect temporal annotation conflicts."""
|
|
575
|
+
data = self._t.post(f"{self._base}/qa/temporal-conflicts")
|
|
576
|
+
return TemporalConflictModel.model_validate(data)
|
|
577
|
+
|
|
578
|
+
def resolve_temporal_conflicts(
|
|
579
|
+
self, resolutions: list[dict[str, Any]]
|
|
580
|
+
) -> ResolveTemporalConflictResponse:
|
|
581
|
+
"""Resolve temporal conflicts."""
|
|
582
|
+
data = self._t.post(
|
|
583
|
+
f"{self._base}/qa/temporal-conflicts/resolve",
|
|
584
|
+
json={"resolutions": resolutions},
|
|
585
|
+
)
|
|
586
|
+
return ResolveTemporalConflictResponse.model_validate(data)
|
|
587
|
+
|
|
588
|
+
# ── Metrics ───────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
def overview(self) -> OverviewModel:
|
|
591
|
+
"""Get comprehensive dataset overview statistics."""
|
|
592
|
+
data = self._t.get(f"{self._base}/metrics/overview")
|
|
593
|
+
return OverviewModel.model_validate(data)
|
|
594
|
+
|
|
595
|
+
def class_distribution(self) -> ClassDistributionModel:
|
|
596
|
+
"""Get class distribution across splits."""
|
|
597
|
+
data = self._t.get(f"{self._base}/metrics/class-distribution")
|
|
598
|
+
return ClassDistributionModel.model_validate(data)
|
|
599
|
+
|
|
600
|
+
def bbox_area(self, bins: int = 10) -> BinnedModel:
|
|
601
|
+
"""Get bounding box area distribution."""
|
|
602
|
+
params: dict[str, Any] = {"bins": bins}
|
|
603
|
+
data = self._t.get(f"{self._base}/metrics/bbox-area", params=params)
|
|
604
|
+
return BinnedModel.model_validate(data)
|
|
605
|
+
|
|
606
|
+
# ── Few-Shot ──────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
def few_shot_preview(self, request: FewShotRequest) -> FewShotPreviewModel:
|
|
609
|
+
"""Preview few-shot dataset creation."""
|
|
610
|
+
data = self._t.post(f"{self._base}/few-shot/preview", json=request.model_dump())
|
|
611
|
+
return FewShotPreviewModel.model_validate(data)
|
|
612
|
+
|
|
613
|
+
def few_shot_create(self, request: FewShotRequest) -> FewShotResponseModel:
|
|
614
|
+
"""Create a few-shot dataset."""
|
|
615
|
+
data = self._t.post(f"{self._base}/few-shot", json=request.model_dump())
|
|
616
|
+
return FewShotResponseModel.model_validate(data)
|
|
617
|
+
|
|
618
|
+
# ── Oversample ────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
def oversample_analyze(self, request: OversampleRequest) -> OversampleAnalysisModel:
|
|
621
|
+
"""Analyze oversampling potential."""
|
|
622
|
+
data = self._t.post(f"{self._base}/oversample/analyze", json=request.model_dump())
|
|
623
|
+
return OversampleAnalysisModel.model_validate(data)
|
|
624
|
+
|
|
625
|
+
def oversample_execute(self, request: OversampleRequest) -> OversampleResponseModel:
|
|
626
|
+
"""Execute oversampling."""
|
|
627
|
+
data = self._t.post(f"{self._base}/oversample", json=request.model_dump())
|
|
628
|
+
return OversampleResponseModel.model_validate(data)
|
|
629
|
+
|
|
630
|
+
# ── Filter ────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
def filter(self, request: DatasetFilterRequest) -> DatasetFilterResponse:
|
|
633
|
+
"""Apply filters to create a filtered dataset."""
|
|
634
|
+
data = self._t.post(f"{self._base}/filter", json=request.model_dump(exclude_none=True))
|
|
635
|
+
return DatasetFilterResponse.model_validate(data)
|
|
636
|
+
|
|
637
|
+
def filter_preview(self, request: DatasetFilterRequest) -> DatasetFilterResponse:
|
|
638
|
+
"""Preview filter results without applying."""
|
|
639
|
+
req = request.model_copy(update={"dry_run": True})
|
|
640
|
+
data = self._t.post(
|
|
641
|
+
f"{self._base}/filter/preview", json=req.model_dump(exclude_none=True)
|
|
642
|
+
)
|
|
643
|
+
return DatasetFilterResponse.model_validate(data)
|
|
644
|
+
|
|
645
|
+
# ── Export ─────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
def export_coco(
|
|
648
|
+
self,
|
|
649
|
+
reindex_images: bool = True,
|
|
650
|
+
reindex_annotations: bool = True,
|
|
651
|
+
reindex_categories: bool = True,
|
|
652
|
+
starting_id: int = 1,
|
|
653
|
+
) -> ExportCocoModel:
|
|
654
|
+
"""Export entire dataset as COCO JSON."""
|
|
655
|
+
data = self._t.post(
|
|
656
|
+
f"{self._base}/export/coco",
|
|
657
|
+
json={
|
|
658
|
+
"reindex_images": reindex_images,
|
|
659
|
+
"reindex_annotations": reindex_annotations,
|
|
660
|
+
"reindex_categories": reindex_categories,
|
|
661
|
+
"starting_id": starting_id,
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
return ExportCocoModel.model_validate(data)
|
|
665
|
+
|
|
666
|
+
# ── History / Undo / Redo ─────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
def history(self) -> HistoryModel:
|
|
669
|
+
"""Get dataset change history."""
|
|
670
|
+
data = self._t.get(f"{self._base}/history")
|
|
671
|
+
return HistoryModel.model_validate(data)
|
|
672
|
+
|
|
673
|
+
def preview_undo(self) -> PreviewModel:
|
|
674
|
+
"""Preview what an undo operation would do."""
|
|
675
|
+
data = self._t.get(f"{self._base}/history/preview-undo")
|
|
676
|
+
return PreviewModel.model_validate(data)
|
|
677
|
+
|
|
678
|
+
def preview_redo(self) -> PreviewModel:
|
|
679
|
+
"""Preview what a redo operation would do."""
|
|
680
|
+
data = self._t.get(f"{self._base}/history/preview-redo")
|
|
681
|
+
return PreviewModel.model_validate(data)
|
|
682
|
+
|
|
683
|
+
def undo(self) -> UndoRedoResponse:
|
|
684
|
+
"""Undo the last operation."""
|
|
685
|
+
data = self._t.post(f"{self._base}/history/undo")
|
|
686
|
+
return UndoRedoResponse.model_validate(data)
|
|
687
|
+
|
|
688
|
+
def redo(self) -> UndoRedoResponse:
|
|
689
|
+
"""Redo the last undone operation."""
|
|
690
|
+
data = self._t.post(f"{self._base}/history/redo")
|
|
691
|
+
return UndoRedoResponse.model_validate(data)
|
|
692
|
+
|
|
693
|
+
def rewind(self, target_change_id: str | UUID) -> RewindResponse:
|
|
694
|
+
"""Rewind to a specific point in history."""
|
|
695
|
+
data = self._t.post(
|
|
696
|
+
f"{self._base}/history/rewind",
|
|
697
|
+
json={"target_change_id": str(target_change_id)},
|
|
698
|
+
)
|
|
699
|
+
return RewindResponse.model_validate(data)
|
|
700
|
+
|
|
701
|
+
# ── DataFrame helpers ─────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
def categories_df(self) -> Any:
|
|
704
|
+
"""Return categories as a pandas DataFrame."""
|
|
705
|
+
from modelstudio._pandas import to_dataframe
|
|
706
|
+
|
|
707
|
+
cat_list = self.categories()
|
|
708
|
+
return to_dataframe(cat_list.categories)
|
|
709
|
+
|
|
710
|
+
def class_distribution_df(self) -> Any:
|
|
711
|
+
"""Return class distribution as a pandas DataFrame."""
|
|
712
|
+
from modelstudio._pandas import _get_pandas
|
|
713
|
+
|
|
714
|
+
pd = _get_pandas()
|
|
715
|
+
dist = self.class_distribution()
|
|
716
|
+
return pd.DataFrame(dist.classes)
|
|
717
|
+
|
|
718
|
+
def images_df(self, **kwargs: Any) -> Any:
|
|
719
|
+
"""Return paginated images as a pandas DataFrame."""
|
|
720
|
+
from modelstudio._pandas import _get_pandas
|
|
721
|
+
|
|
722
|
+
pd = _get_pandas()
|
|
723
|
+
paged = self.list_images(**kwargs)
|
|
724
|
+
return pd.DataFrame(paged.content)
|
|
725
|
+
|
|
726
|
+
def annotations_df(self, **kwargs: Any) -> Any:
|
|
727
|
+
"""Return paginated annotations as a pandas DataFrame."""
|
|
728
|
+
from modelstudio._pandas import _get_pandas
|
|
729
|
+
|
|
730
|
+
pd = _get_pandas()
|
|
731
|
+
paged = self.list_annotations(**kwargs)
|
|
732
|
+
return pd.DataFrame(paged.content)
|