|
28 | 28 | from ...annotation_types.audio import (
|
29 | 29 | AudioClassificationAnnotation,
|
30 | 30 | )
|
| 31 | +from .temporal import create_audio_ndjson_annotations |
31 | 32 | from labelbox.types import DocumentRectangle, DocumentEntity
|
32 | 33 | from .classification import (
|
33 | 34 | NDChecklistSubclass,
|
@@ -169,190 +170,25 @@ def _create_video_annotations(
|
169 | 170 | def _create_audio_annotations(
|
170 | 171 | cls, label: Label
|
171 | 172 | ) -> Generator[BaseModel, None, None]:
|
172 |
| - """Create audio annotations with nested classifications (v3-like), |
173 |
| - while preserving v2 behavior for non-nested cases. |
174 |
| -
|
175 |
| - Strategy: |
176 |
| - - Group audio annotations by classification (schema_id or name) |
177 |
| - - Identify root groups (not fully contained by another group's frames) |
178 |
| - - For each root group, build answer items grouped by value with frames |
179 |
| - - Recursively attach nested classifications by time containment |
180 |
| - """ |
181 |
| - |
182 |
| - # 1) Collect all audio annotations grouped by classification key |
183 |
| - # Use feature_schema_id when present, otherwise fall back to name |
184 |
| - audio_by_group: Dict[str, List[AudioClassificationAnnotation]] = defaultdict(list) |
185 |
| - for annot in label.annotations: |
186 |
| - if isinstance(annot, AudioClassificationAnnotation): |
187 |
| - audio_by_group[annot.feature_schema_id or annot.name].append(annot) |
188 |
| - |
189 |
| - if not audio_by_group: |
| 173 | + """Create audio annotations with nested classifications using modular hierarchy builder.""" |
| 174 | + # Extract audio annotations from the label |
| 175 | + audio_annotations = [ |
| 176 | + annot for annot in label.annotations |
| 177 | + if isinstance(annot, AudioClassificationAnnotation) |
| 178 | + ] |
| 179 | + |
| 180 | + if not audio_annotations: |
190 | 181 | return
|
191 |
| - |
192 |
| - # Helper: produce a user-facing classification name for a group |
193 |
| - def group_display_name(group_key: str, anns: List[AudioClassificationAnnotation]) -> str: |
194 |
| - # Prefer the first non-empty annotation name |
195 |
| - for a in anns: |
196 |
| - if a.name: |
197 |
| - return a.name |
198 |
| - # Fallback to group key (may be schema id) |
199 |
| - return group_key |
200 |
| - |
201 |
| - # Helper: compute whether group A is fully contained by any other group by time |
202 |
| - def is_group_nested(group_key: str) -> bool: |
203 |
| - anns = audio_by_group[group_key] |
204 |
| - for ann in anns: |
205 |
| - # An annotation is considered nested if there exists any container in other groups |
206 |
| - contained = False |
207 |
| - for other_key, other_anns in audio_by_group.items(): |
208 |
| - if other_key == group_key: |
209 |
| - continue |
210 |
| - for parent in other_anns: |
211 |
| - if parent.start_frame <= ann.start_frame and ( |
212 |
| - parent.end_frame is not None |
213 |
| - and ann.end_frame is not None |
214 |
| - and parent.end_frame >= ann.end_frame |
215 |
| - ): |
216 |
| - contained = True |
217 |
| - break |
218 |
| - if contained: |
219 |
| - break |
220 |
| - if not contained: |
221 |
| - # If any annotation in this group is not contained, group is a root |
222 |
| - return False |
223 |
| - # All annotations were contained somewhere → nested group |
224 |
| - return True |
225 |
| - |
226 |
| - # Helper: group annotations by logical value and produce answer entries |
227 |
| - def group_by_value(annotations: List[AudioClassificationAnnotation]) -> List[Dict[str, Any]]: |
228 |
| - value_buckets: Dict[str, List[AudioClassificationAnnotation]] = defaultdict(list) |
229 |
| - |
230 |
| - for ann in annotations: |
231 |
| - # Compute grouping key depending on classification type |
232 |
| - if hasattr(ann.value, "answer"): |
233 |
| - if isinstance(ann.value.answer, list): |
234 |
| - # Checklist: stable key from selected option names |
235 |
| - key = str(sorted([opt.name for opt in ann.value.answer])) |
236 |
| - elif hasattr(ann.value.answer, "name"): |
237 |
| - # Radio: option name |
238 |
| - key = ann.value.answer.name |
239 |
| - else: |
240 |
| - # Text: the string value |
241 |
| - key = ann.value.answer |
242 |
| - else: |
243 |
| - key = str(ann.value) |
244 |
| - value_buckets[key].append(ann) |
245 |
| - |
246 |
| - entries: List[Dict[str, Any]] = [] |
247 |
| - for _, anns in value_buckets.items(): |
248 |
| - first = anns[0] |
249 |
| - frames = [{"start": a.start_frame, "end": a.end_frame} for a in anns] |
250 |
| - |
251 |
| - if hasattr(first.value, "answer") and isinstance(first.value.answer, list): |
252 |
| - # Checklist: emit one entry per distinct option present in this bucket |
253 |
| - # Since bucket is keyed by the combination, take names from first |
254 |
| - for opt_name in sorted([o.name for o in first.value.answer]): |
255 |
| - entries.append({"name": opt_name, "frames": frames}) |
256 |
| - elif hasattr(first.value, "answer") and hasattr(first.value.answer, "name"): |
257 |
| - # Radio |
258 |
| - entries.append({"name": first.value.answer.name, "frames": frames}) |
259 |
| - else: |
260 |
| - # Text |
261 |
| - entries.append({"value": first.value.answer, "frames": frames}) |
262 |
| - |
263 |
| - return entries |
264 |
| - |
265 |
| - # Helper: check if child ann is inside any of the parent frames list |
266 |
| - def ann_within_frames(ann: AudioClassificationAnnotation, frames: List[Dict[str, int]]) -> bool: |
267 |
| - for fr in frames: |
268 |
| - if fr["start"] <= ann.start_frame and ( |
269 |
| - ann.end_frame is not None and fr["end"] is not None and fr["end"] >= ann.end_frame |
270 |
| - ): |
271 |
| - return True |
272 |
| - return False |
273 |
| - |
274 |
| - # Helper: recursively build nested classifications for a specific parent frames list |
275 |
| - def build_nested_for_frames(parent_frames: List[Dict[str, int]], exclude_group: str) -> List[Dict[str, Any]]: |
276 |
| - nested: List[Dict[str, Any]] = [] |
277 |
| - |
278 |
| - # Collect all annotations within parent frames across all groups except the excluded one |
279 |
| - all_contained: List[AudioClassificationAnnotation] = [] |
280 |
| - for gk, ga in audio_by_group.items(): |
281 |
| - if gk == exclude_group: |
282 |
| - continue |
283 |
| - all_contained.extend([a for a in ga if ann_within_frames(a, parent_frames)]) |
284 |
| - |
285 |
| - def strictly_contains(container: AudioClassificationAnnotation, inner: AudioClassificationAnnotation) -> bool: |
286 |
| - if container is inner: |
287 |
| - return False |
288 |
| - if container.end_frame is None or inner.end_frame is None: |
289 |
| - return False |
290 |
| - return container.start_frame <= inner.start_frame and container.end_frame >= inner.end_frame and ( |
291 |
| - container.start_frame < inner.start_frame or container.end_frame > inner.end_frame |
292 |
| - ) |
293 |
| - |
294 |
| - for group_key, anns in audio_by_group.items(): |
295 |
| - if group_key == exclude_group: |
296 |
| - continue |
297 |
| - # Do not nest groups that are roots themselves to avoid duplicating top-level groups inside others |
298 |
| - if group_key in root_group_keys: |
299 |
| - continue |
300 |
| - |
301 |
| - # Filter annotations that are contained by any parent frame |
302 |
| - candidate_anns = [a for a in anns if ann_within_frames(a, parent_frames)] |
303 |
| - if not candidate_anns: |
304 |
| - continue |
305 |
| - |
306 |
| - # Keep only immediate children (those not strictly contained by another contained annotation) |
307 |
| - child_anns = [] |
308 |
| - for a in candidate_anns: |
309 |
| - has_closer_container = any(strictly_contains(b, a) for b in all_contained) |
310 |
| - if not has_closer_container: |
311 |
| - child_anns.append(a) |
312 |
| - if not child_anns: |
313 |
| - continue |
314 |
| - |
315 |
| - # Build this child classification block |
316 |
| - child_entries = group_by_value(child_anns) |
317 |
| - # Recurse: for each answer entry, compute further nested |
318 |
| - for entry in child_entries: |
319 |
| - entry_frames = entry.get("frames", []) |
320 |
| - child_nested = build_nested_for_frames(entry_frames, group_key) |
321 |
| - if child_nested: |
322 |
| - entry["classifications"] = child_nested |
323 |
| - |
324 |
| - nested.append({ |
325 |
| - "name": group_display_name(group_key, anns), |
326 |
| - "answer": child_entries, |
327 |
| - }) |
328 |
| - |
329 |
| - return nested |
330 |
| - |
331 |
| - # 2) Determine root groups (not fully contained by other groups) |
332 |
| - root_group_keys = [k for k in audio_by_group.keys() if not is_group_nested(k)] |
333 |
| - |
334 |
| - # 3) Emit one NDJSON object per root classification group |
335 |
| - class AudioNDJSON(BaseModel): |
336 |
| - name: str |
337 |
| - answer: List[Dict[str, Any]] |
338 |
| - dataRow: Dict[str, str] |
339 |
| - |
340 |
| - for group_key in root_group_keys: |
341 |
| - anns = audio_by_group[group_key] |
342 |
| - top_entries = group_by_value(anns) |
343 |
| - |
344 |
| - # Attach nested to each top-level answer entry |
345 |
| - for entry in top_entries: |
346 |
| - frames = entry.get("frames", []) |
347 |
| - children = build_nested_for_frames(frames, group_key) |
348 |
| - if children: |
349 |
| - entry["classifications"] = children |
350 |
| - |
351 |
| - yield AudioNDJSON( |
352 |
| - name=group_display_name(group_key, anns), |
353 |
| - answer=top_entries, |
354 |
| - dataRow={"globalKey": label.data.global_key}, |
355 |
| - ) |
| 182 | + |
| 183 | + # Use the modular hierarchy builder to create NDJSON annotations |
| 184 | + ndjson_annotations = create_audio_ndjson_annotations( |
| 185 | + audio_annotations, |
| 186 | + label.data.global_key |
| 187 | + ) |
| 188 | + |
| 189 | + # Yield each NDJSON annotation |
| 190 | + for annotation in ndjson_annotations: |
| 191 | + yield annotation |
356 | 192 |
|
357 | 193 |
|
358 | 194 |
|
|
0 commit comments