3lc-compute-plugin-sdk 0.2.2__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,551 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared modality detection — single source of truth for all table/schema inspection.
4
+
5
+ Detects whether a table represents a detection, classification, segmentation,
6
+ pose, or OBB task by walking the schema tree. Used by insights, training, and
7
+ auto-labeling plugins.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Canonical sample_type → modality mapping
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _SAMPLE_TYPE_MAP: dict[str, str] = {
24
+ "bounding_boxes": "detection",
25
+ "bounding_box": "detection",
26
+ "instance_segmentation_masks": "segmentation",
27
+ "instance_segmentation": "segmentation",
28
+ "semantic_segmentation": "segmentation",
29
+ "segmentation_masks": "segmentation",
30
+ "segmentation": "segmentation",
31
+ "categorical_label": "classification",
32
+ "classification": "classification",
33
+ "pose": "pose",
34
+ "keypoints": "pose",
35
+ "obb": "obb",
36
+ "oriented_bounding_boxes": "obb",
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class ModalityInfo:
42
+ """Result of modality detection."""
43
+
44
+ modality: str = "unknown"
45
+ """Detected modality: detection, segmentation, classification, pose, obb, or unknown."""
46
+
47
+ gt_col: str | None = None
48
+ """Ground-truth column name (e.g. ``bbs``, ``segmentations``, ``label``)."""
49
+
50
+ pred_col: str | None = None
51
+ """Prediction column name (e.g. ``bbs_predicted``, ``predicted``)."""
52
+
53
+ class_names: dict[str, str] = field(default_factory=dict)
54
+ """Class index → display name mapping."""
55
+
56
+ image_column: str | None = None
57
+ """Image column name (if detected — first one found)."""
58
+
59
+ image_columns: list[str] = field(default_factory=list)
60
+ """All image columns detected in the schema."""
61
+
62
+ label_column: str | None = None
63
+ """Label column name for classification tasks."""
64
+
65
+ num_classes: int = 0
66
+ """Number of classes detected from value maps."""
67
+
68
+ classification_columns: list[str] = field(default_factory=list)
69
+ """All columns identified as classification-type."""
70
+
71
+ detection_columns: list[str] = field(default_factory=list)
72
+ """All columns identified as detection-type."""
73
+
74
+ segmentation_columns: list[str] = field(default_factory=list)
75
+ """All columns identified as segmentation-type."""
76
+
77
+ all_columns: list[str] = field(default_factory=list)
78
+ """All column names in the schema."""
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Core detection from a schema object
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def detect_modality_from_schema(schema: Any) -> ModalityInfo:
87
+ """Walk a schema object and detect modality.
88
+
89
+ This is the core detection function. It does not load any table or URL —
90
+ callers pass in ``table.rows_schema`` directly.
91
+
92
+ Detection priority:
93
+ 1. ``sample_type`` attribute (canonical SDK identifier)
94
+ 2. Nested schema keys (``bb_list``, ``rles``, ``instance_properties``)
95
+ 3. Column name conventions (``bbs``, ``segmentations``, ``masks``)
96
+ 4. Python class names (``BoundingBoxListValue``, ``CategoricalLabel``)
97
+ 5. ``value.type`` string, ``number_role``, ``description``
98
+ 6. Heuristic: image column + label map → classification
99
+ 7. JSON fallback (``schema.to_json()``) if Python introspection fails
100
+
101
+ Args:
102
+ schema: A 3LC schema object with a ``values`` dict of column schemas.
103
+
104
+ Returns:
105
+ ModalityInfo with all detected fields.
106
+
107
+ """
108
+ info = ModalityInfo()
109
+
110
+ if not schema or not hasattr(schema, "values"):
111
+ return info
112
+
113
+ try:
114
+ return _detect_from_python(schema, info)
115
+ except Exception:
116
+ logger.debug("Python schema introspection failed, trying JSON fallback", exc_info=True)
117
+ try:
118
+ return _detect_from_json(schema, info)
119
+ except Exception:
120
+ logger.debug("JSON fallback also failed", exc_info=True)
121
+ return info
122
+
123
+
124
+ def _detect_from_python(schema: Any, info: ModalityInfo) -> ModalityInfo:
125
+ """Detect modality using Python attribute access on the schema."""
126
+ values = schema.values
127
+ info.all_columns = sorted(values.keys())
128
+
129
+ best_cls_col: str | None = None
130
+ best_det_col: str | None = None
131
+ best_seg_col: str | None = None
132
+
133
+ for key, col_schema in values.items():
134
+ # 1. Check column-level sample_type
135
+ col_task = _sample_type_to_modality(_get_sample_type(col_schema))
136
+ if col_task:
137
+ _assign_column(info, col_task, key)
138
+ if col_task == "classification" and best_cls_col is None:
139
+ best_cls_col = key
140
+ elif col_task == "detection" and best_det_col is None:
141
+ best_det_col = key
142
+ elif col_task == "segmentation" and best_seg_col is None:
143
+ best_seg_col = key
144
+ continue
145
+
146
+ # 1b. Walk nested schemas for sample_type (e.g. instance_properties)
147
+ if hasattr(col_schema, "values") and isinstance(col_schema.values, dict):
148
+ nested_hit = False
149
+ for _sk, sub_schema in col_schema.values.items():
150
+ sub_task = _sample_type_to_modality(_get_sample_type(sub_schema))
151
+ if sub_task:
152
+ _assign_column(info, sub_task, key)
153
+ if sub_task == "classification" and best_cls_col is None:
154
+ best_cls_col = key
155
+ elif sub_task == "detection" and best_det_col is None:
156
+ best_det_col = key
157
+ elif sub_task == "segmentation" and best_seg_col is None:
158
+ best_seg_col = key
159
+ nested_hit = True
160
+ break
161
+ if nested_hit:
162
+ continue
163
+
164
+ # 2. Nested schema keys: bb_list, rles, instance_properties, oriented_bbs, keypoints
165
+ if hasattr(col_schema, "values") and isinstance(col_schema.values, dict):
166
+ nested_keys = col_schema.values
167
+ if "rles" in nested_keys or "instance_properties" in nested_keys:
168
+ info.segmentation_columns.append(key)
169
+ if best_seg_col is None:
170
+ best_seg_col = key
171
+ continue
172
+ if "bb_list" in nested_keys:
173
+ info.detection_columns.append(key)
174
+ if best_det_col is None:
175
+ best_det_col = key
176
+ continue
177
+ # Check nested instances.values for pose/obb keys
178
+ all_nested = set(nested_keys.keys())
179
+ instances_sub = nested_keys.get("instances")
180
+ if instances_sub and hasattr(instances_sub, "values") and isinstance(instances_sub.values, dict):
181
+ all_nested.update(instances_sub.values.keys())
182
+ for nk in all_nested:
183
+ nkl = nk.lower()
184
+ if "oriented_bb" in nkl or nkl == "obb":
185
+ info.modality = "obb"
186
+ info.gt_col = key
187
+ return info
188
+ if "keypoint" in nkl or "vertices_2d" in nkl or "vertices_3d" in nkl:
189
+ info.modality = "pose"
190
+ info.gt_col = key
191
+ return info
192
+
193
+ # 2b. display_name / internal_name hints
194
+ display_name = (getattr(col_schema, "display_name", "") or "").lower()
195
+ internal_name = (getattr(col_schema, "internal_name", "") or "").lower()
196
+ for hint_name in (display_name, internal_name):
197
+ if not hint_name:
198
+ continue
199
+ if hint_name == "obb" or "oriented_b" in hint_name:
200
+ info.modality = "obb"
201
+ info.gt_col = key
202
+ return info
203
+ if "keypoint" in hint_name or "pose" in hint_name:
204
+ info.modality = "pose"
205
+ info.gt_col = key
206
+ return info
207
+
208
+ # 3. Column name conventions
209
+ col_name_mod = _column_name_to_modality(key)
210
+ if col_name_mod == "segmentation":
211
+ info.segmentation_columns.append(key)
212
+ if best_seg_col is None:
213
+ best_seg_col = key
214
+ continue
215
+ if col_name_mod == "detection":
216
+ info.detection_columns.append(key)
217
+ if best_det_col is None:
218
+ best_det_col = key
219
+ continue
220
+ if col_name_mod in ("pose", "obb"):
221
+ info.modality = col_name_mod
222
+ info.gt_col = key
223
+ return info
224
+
225
+ if not hasattr(col_schema, "value"):
226
+ # Check description for bounding box hints
227
+ desc = getattr(col_schema, "description", "") or ""
228
+ if "bounding" in desc.lower():
229
+ info.detection_columns.append(key)
230
+ if best_det_col is None:
231
+ best_det_col = key
232
+ continue
233
+
234
+ value_obj = col_schema.value
235
+ type_name = type(value_obj).__name__.lower()
236
+
237
+ # 4. Python class names
238
+ if "boundingbox" in type_name or "bounding_box" in type_name:
239
+ info.detection_columns.append(key)
240
+ if best_det_col is None:
241
+ best_det_col = key
242
+ continue
243
+ if "segmentation" in type_name or "mask" in type_name:
244
+ info.segmentation_columns.append(key)
245
+ if best_seg_col is None:
246
+ best_seg_col = key
247
+ continue
248
+ if "pose" in type_name or "keypoint" in type_name:
249
+ # pose/obb don't go into column lists — just set modality directly
250
+ info.modality = "pose"
251
+ info.gt_col = key
252
+ return info
253
+
254
+ # 5a. value.type string
255
+ if hasattr(value_obj, "type"):
256
+ vtype = str(value_obj.type).lower()
257
+ if "bounding_box" in vtype or "boundingbox" in vtype:
258
+ info.detection_columns.append(key)
259
+ if best_det_col is None:
260
+ best_det_col = key
261
+ continue
262
+ if "segmentation" in vtype or "mask" in vtype:
263
+ info.segmentation_columns.append(key)
264
+ if best_seg_col is None:
265
+ best_seg_col = key
266
+ continue
267
+ if "pose" in vtype or "keypoint" in vtype:
268
+ info.modality = "pose"
269
+ info.gt_col = key
270
+ return info
271
+
272
+ # 5b. sample_type on col_schema (not value) for classification
273
+ sample_type = getattr(col_schema, "sample_type", "") or ""
274
+ if "categorical" in str(sample_type).lower():
275
+ info.classification_columns.append(key)
276
+ if best_cls_col is None:
277
+ best_cls_col = key
278
+ continue
279
+
280
+ # 5c. number_role == "label"
281
+ number_role = getattr(value_obj, "number_role", "") or ""
282
+ if "label" in str(number_role).lower():
283
+ info.classification_columns.append(key)
284
+ if best_cls_col is None:
285
+ best_cls_col = key
286
+ continue
287
+
288
+ # 5d. value.map dict present with entries (classification)
289
+ value_map = getattr(value_obj, "map", None)
290
+ if isinstance(value_map, dict) and len(value_map) > 0:
291
+ info.classification_columns.append(key)
292
+ if best_cls_col is None:
293
+ best_cls_col = key
294
+ continue
295
+
296
+ # 5e. Description containing "bounding"
297
+ desc = getattr(col_schema, "description", "") or ""
298
+ if "bounding" in desc.lower():
299
+ info.detection_columns.append(key)
300
+ if best_det_col is None:
301
+ best_det_col = key
302
+ continue
303
+
304
+ # Track image column
305
+ if hasattr(value_obj, "string_role"):
306
+ role = str(value_obj.string_role).lower()
307
+ if "image" in role:
308
+ info.image_columns.append(key)
309
+ if info.image_column is None:
310
+ info.image_column = key
311
+
312
+ # Priority: segmentation > detection > classification
313
+ if info.segmentation_columns:
314
+ info.modality = "segmentation"
315
+ info.gt_col = best_seg_col
316
+ elif info.detection_columns:
317
+ info.modality = "detection"
318
+ info.gt_col = best_det_col
319
+ elif info.classification_columns:
320
+ info.modality = "classification"
321
+ info.gt_col = best_cls_col
322
+ info.label_column = best_cls_col
323
+ # Assign pred_col by convention
324
+ if info.gt_col:
325
+ pred_candidate = info.gt_col + "_predicted"
326
+ if pred_candidate in values:
327
+ info.pred_col = pred_candidate
328
+ elif "predicted" in values:
329
+ info.pred_col = "predicted"
330
+ elif info.gt_col + "Predicted" in values:
331
+ info.pred_col = info.gt_col + "Predicted"
332
+
333
+ return info
334
+
335
+
336
+ def _detect_from_json(schema: Any, info: ModalityInfo) -> ModalityInfo:
337
+ """Fallback: detect modality by parsing schema.to_json()."""
338
+ schema_json = json.loads(schema.to_json())
339
+ vals = schema_json.get("values", {})
340
+ info.all_columns = sorted(vals.keys())
341
+
342
+ for key, col_def in vals.items():
343
+ key_lower = key.lower()
344
+ nested_vals = col_def.get("values", {})
345
+
346
+ # Segmentation
347
+ if isinstance(nested_vals, dict) and ("rles" in nested_vals or "instance_properties" in nested_vals):
348
+ info.segmentation_columns.append(key)
349
+ if info.gt_col is None:
350
+ info.gt_col = key
351
+ continue
352
+ if key_lower in ("segmentations", "segmentation", "masks", "segmentations_predicted"):
353
+ info.segmentation_columns.append(key)
354
+ if info.gt_col is None:
355
+ info.gt_col = key
356
+ continue
357
+
358
+ # Detection
359
+ if isinstance(nested_vals, dict) and "bb_list" in nested_vals:
360
+ info.detection_columns.append(key)
361
+ if info.gt_col is None:
362
+ info.gt_col = key
363
+ continue
364
+ if key_lower in ("bbs", "bounding_boxes"):
365
+ info.detection_columns.append(key)
366
+ if info.gt_col is None:
367
+ info.gt_col = key
368
+ continue
369
+
370
+ # Classification
371
+ if col_def.get("sample_type", "") == "categorical_label":
372
+ info.classification_columns.append(key)
373
+ if info.gt_col is None:
374
+ info.gt_col = key
375
+ continue
376
+ val_def = col_def.get("value", {})
377
+ if isinstance(val_def, dict):
378
+ if val_def.get("number_role", "") == "label":
379
+ info.classification_columns.append(key)
380
+ if info.gt_col is None:
381
+ info.gt_col = key
382
+ continue
383
+ if isinstance(val_def.get("map"), dict) and val_def["map"]:
384
+ info.classification_columns.append(key)
385
+ if info.gt_col is None:
386
+ info.gt_col = key
387
+ continue
388
+
389
+ if info.segmentation_columns:
390
+ info.modality = "segmentation"
391
+ elif info.detection_columns:
392
+ info.modality = "detection"
393
+ elif info.classification_columns:
394
+ info.modality = "classification"
395
+
396
+ return info
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Higher-level entry points
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ def detect_modality_from_table(table: Any) -> ModalityInfo:
405
+ """Detect modality from a loaded table object.
406
+
407
+ Args:
408
+ table: A loaded ``tlc.Table`` object.
409
+
410
+ Returns:
411
+ ModalityInfo with modality, columns, and class names extracted.
412
+
413
+ """
414
+ info = detect_modality_from_schema(table.rows_schema)
415
+
416
+ # Extract class names from value map
417
+ if info.gt_col:
418
+ info.class_names = _extract_class_names(table, info.gt_col)
419
+ if info.class_names:
420
+ info.num_classes = len(info.class_names)
421
+
422
+ return info
423
+
424
+
425
+ def detect_modality_from_url(url: str) -> ModalityInfo:
426
+ """Load a table from URL and detect its modality.
427
+
428
+ Convenience wrapper that loads the table first, then delegates to
429
+ ``detect_modality_from_table``.
430
+
431
+ Args:
432
+ url: 3LC table URL (will be normalized).
433
+
434
+ Returns:
435
+ ModalityInfo with full detection results.
436
+
437
+ """
438
+ import tlc
439
+
440
+ from tlc_plugin_sdk.shared.url_utils import normalize_url
441
+
442
+ table = tlc.Table.from_url(normalize_url(url))
443
+ return detect_modality_from_table(table)
444
+
445
+
446
+ def classify_metrics_columns(columns: set[str]) -> str:
447
+ """Classify a metrics table by its column set.
448
+
449
+ Used by run statistics to quickly determine the modality of a metrics
450
+ table before doing full schema inspection.
451
+
452
+ Args:
453
+ columns: Set of column names from the table.
454
+
455
+ Returns:
456
+ One of: classification, detection_aggregate, detection_sample,
457
+ segmentation_sample, or unknown.
458
+
459
+ """
460
+ if "example_id" in columns and "predicted" in columns:
461
+ return "classification"
462
+ if "label" in columns and ("mAP" in columns or "mAP50-95" in columns):
463
+ return "detection_aggregate"
464
+ if "segmentations_predicted" in columns:
465
+ return "segmentation_sample"
466
+ if "bbs_predicted" in columns:
467
+ return "detection_sample"
468
+ # Pose / keypoints
469
+ for c in columns:
470
+ cl = c.lower()
471
+ if "keypoint" in cl or cl.startswith("pose") or "vertices_2d" in cl or "vertices_3d" in cl:
472
+ return "pose"
473
+ if cl in ("obb", "oriented_bounding_boxes") or cl.startswith("obb_"):
474
+ return "obb"
475
+ return "unknown"
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # Helpers
480
+ # ---------------------------------------------------------------------------
481
+
482
+
483
+ def _get_sample_type(obj: Any) -> str:
484
+ """Extract sample_type string from a schema object."""
485
+ st = getattr(obj, "sample_type", None)
486
+ if st and isinstance(st, str):
487
+ return st.lower()
488
+ return ""
489
+
490
+
491
+ def _sample_type_to_modality(sample_type: str) -> str:
492
+ """Convert a sample_type string to a modality name, or empty string."""
493
+ if not sample_type:
494
+ return ""
495
+ if sample_type in _SAMPLE_TYPE_MAP:
496
+ return _SAMPLE_TYPE_MAP[sample_type]
497
+ for key, modality in _SAMPLE_TYPE_MAP.items():
498
+ if key in sample_type:
499
+ return modality
500
+ return ""
501
+
502
+
503
+ def _column_name_to_modality(col_name: str) -> str:
504
+ """Detect modality from column name using conventions."""
505
+ try:
506
+ import tlc
507
+
508
+ if col_name == getattr(tlc, "BOUNDING_BOXES", "bbs") or col_name == "bbs":
509
+ return "detection"
510
+ if col_name == getattr(tlc, "SEGMENTATIONS", "segmentations") or col_name == "segmentations":
511
+ return "segmentation"
512
+ except ImportError:
513
+ pass
514
+
515
+ cl = col_name.lower()
516
+ if cl in ("bbs", "bounding_boxes", "bboxes"):
517
+ return "detection"
518
+ if cl in ("segmentations", "segmentation", "masks", "segmentations_predicted", "instance_segmentation"):
519
+ return "segmentation"
520
+ if "keypoint" in cl or cl.startswith("pose") or "vertices_2d" in cl or "vertices_3d" in cl:
521
+ return "pose"
522
+ if cl in ("obb", "oriented_bounding_boxes") or cl.startswith("obb_"):
523
+ return "obb"
524
+ return ""
525
+
526
+
527
+ def _assign_column(info: ModalityInfo, modality: str, col_name: str) -> None:
528
+ """Add a column to the appropriate modality list in ModalityInfo."""
529
+ if modality == "detection":
530
+ info.detection_columns.append(col_name)
531
+ elif modality == "segmentation":
532
+ info.segmentation_columns.append(col_name)
533
+ elif modality == "classification":
534
+ info.classification_columns.append(col_name)
535
+ elif modality == "pose":
536
+ info.modality = "pose"
537
+ info.gt_col = col_name
538
+ elif modality == "obb":
539
+ info.modality = "obb"
540
+ info.gt_col = col_name
541
+
542
+
543
+ def _extract_class_names(table: Any, column_name: str) -> dict[str, str]:
544
+ """Extract class names from a column's value map.
545
+
546
+ Tries the column directly, then nested instance-label paths (centralized
547
+ in ``tlc_plugin_sdk.shared.labels``).
548
+ """
549
+ from tlc_plugin_sdk.shared.labels import get_label_map
550
+
551
+ return {str(index): name for index, name in get_label_map(table, column_name).items()}