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.
@@ -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)