diff --git a/bats_ai/core/admin/configuration.py b/bats_ai/core/admin/configuration.py index 4d4af01d..923357e0 100644 --- a/bats_ai/core/admin/configuration.py +++ b/bats_ai/core/admin/configuration.py @@ -11,6 +11,7 @@ class ConfigurationAdmin(admin.ModelAdmin): "display_pulse_annotations", "display_sequence_annotations", "run_inference_on_upload", + "create_pulse_annotations_from_batbot", "spectrogram_x_stretch", "spectrogram_view", ] diff --git a/bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py b/bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py new file mode 100644 index 00000000..5b702d4c --- /dev/null +++ b/bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2026-06-11 00:00 +from __future__ import annotations + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0040_alter_grtscells_id"), + ] + + operations = [ + migrations.AddField( + model_name="configuration", + name="create_pulse_annotations_from_batbot", + field=models.BooleanField(default=False), + ), + ] diff --git a/bats_ai/core/models/configuration.py b/bats_ai/core/models/configuration.py index 19d4428e..d277a47d 100644 --- a/bats_ai/core/models/configuration.py +++ b/bats_ai/core/models/configuration.py @@ -22,6 +22,7 @@ class AvailableColorScheme(models.TextChoices): display_pulse_annotations = models.BooleanField(default=True) display_sequence_annotations = models.BooleanField(default=True) run_inference_on_upload = models.BooleanField(default=True) + create_pulse_annotations_from_batbot = models.BooleanField(default=False) spectrogram_x_stretch = models.DecimalField(default=2.5, max_digits=3, decimal_places=2) spectrogram_view = models.CharField( max_length=12, choices=SpectrogramViewMode, default=SpectrogramViewMode.COMPRESSED diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index 0bae446c..23cf63ab 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -228,6 +228,15 @@ def recording_compute_spectrogram(self, recording_id: int): # noqa: C901, PLR09 pulse_metadata_obj.contours = [] pulse_metadata_obj.save() + from bats_ai.core.utils.batbot_annotations import ( + create_pulse_annotations_from_batbot_segments, + ) + + create_pulse_annotations_from_batbot_segments( + recording, + compressed["segments"], + ) + if processing_task: processing_task.status = ProcessingTask.Status.COMPLETE processing_task.save() diff --git a/bats_ai/core/utils/batbot_annotations.py b/bats_ai/core/utils/batbot_annotations.py new file mode 100644 index 00000000..936a19b3 --- /dev/null +++ b/bats_ai/core/utils/batbot_annotations.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from bats_ai.core.models import Annotations, Configuration + +if TYPE_CHECKING: + from bats_ai.core.models import Recording + from bats_ai.core.utils.batbot_metadata import BatBotMetadataCurve + +logger = logging.getLogger(__name__) + +BATBOT_ANNOTATION_MODEL = "batbot" + + +def _segment_bounds( + segment: BatBotMetadataCurve, +) -> tuple[float, float, float, float] | None: + curve = segment.get("curve_hz_ms") or [] + if not curve: + return None + + times = [pt[1] for pt in curve] + freqs = [pt[0] for pt in curve] + return min(times), max(times), min(freqs), max(freqs) + + +def create_pulse_annotations_from_batbot_segments( + recording: Recording, + segments: list[BatBotMetadataCurve], +) -> int: + """Create pulse annotations from BatBot segments when enabled in Configuration.""" + config = Configuration.objects.first() + if not config or not config.create_pulse_annotations_from_batbot: + return 0 + + Annotations.objects.filter( + recording=recording, + model=BATBOT_ANNOTATION_MODEL, + ).delete() + + created = 0 + for segment in segments: + bounds = _segment_bounds(segment) + if bounds is None: + segment_index = segment.get("segment_index") + logger.warning( + "Skipping BatBot pulse annotation for recording=%s segment_index=%s: no bbox", + recording.pk, + segment_index, + ) + continue + + t_start, t_end, f_lo, f_hi = bounds + Annotations.objects.create( + recording=recording, + owner=recording.owner, + start_time=t_start, + end_time=t_end, + low_freq=f_lo, + high_freq=f_hi, + type="pulse", + model=BATBOT_ANNOTATION_MODEL, + comments="", + ) + created += 1 + + return created diff --git a/bats_ai/core/views/configuration.py b/bats_ai/core/views/configuration.py index 4f0a4b7d..dcae9dd3 100644 --- a/bats_ai/core/views/configuration.py +++ b/bats_ai/core/views/configuration.py @@ -26,6 +26,7 @@ class ConfigurationSchema(Schema): display_sequence_annotations: bool is_admin: bool | None = None run_inference_on_upload: bool + create_pulse_annotations_from_batbot: bool spectrogram_x_stretch: float spectrogram_view: Configuration.SpectrogramViewMode default_color_scheme: Configuration.AvailableColorScheme @@ -44,6 +45,7 @@ def get_configuration(request): display_pulse_annotations=config.display_pulse_annotations, display_sequence_annotations=config.display_sequence_annotations, run_inference_on_upload=config.run_inference_on_upload, + create_pulse_annotations_from_batbot=config.create_pulse_annotations_from_batbot, spectrogram_x_stretch=config.spectrogram_x_stretch, spectrogram_view=config.spectrogram_view, default_color_scheme=config.default_color_scheme, @@ -62,7 +64,7 @@ def update_configuration(request, payload: ConfigurationSchema): config = Configuration.objects.first() if not config: return JsonResponse({"error": "No configuration found"}, status=404) - for attr, value in payload.dict().items(): + for attr, value in payload.dict(exclude={"is_admin"}).items(): setattr(config, attr, value) config.save() return ConfigurationSchema.from_orm(config) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 60c4e64a..a2532064 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -178,12 +178,12 @@ class RecordingPaginatedResponse(Schema): class AnnotationSchema(Schema): - start_time: int - end_time: int - low_freq: int - high_freq: int + start_time: float + end_time: float + low_freq: float + high_freq: float species: list[SpeciesSchema] - comments: str + comments: str = "" type: str | None = None id: int | None = None owner_email: str = None @@ -196,7 +196,7 @@ def from_orm(cls, obj: Annotations, owner_email=None): low_freq=obj.low_freq, high_freq=obj.high_freq, species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], - comments=obj.comments, + comments=obj.comments or "", id=obj.id, type=obj.type, owner_email=owner_email, # Include owner_email in the schema @@ -204,10 +204,10 @@ def from_orm(cls, obj: Annotations, owner_email=None): class UpdateAnnotationsSchema(Schema): - start_time: int | None - end_time: int | None - low_freq: int | None - high_freq: int | None + start_time: float | None + end_time: float | None + low_freq: float | None + high_freq: float | None species: list[SpeciesSchema] | None comments: str | None type: str | None @@ -278,10 +278,10 @@ def linestring_to_list(ls): class SequenceAnnotationSchema(Schema): id: int - start_time: int - end_time: int + start_time: float + end_time: float type: str | None - comments: str + comments: str = "" species: list[SpeciesSchema] | None owner_email: str = None @@ -292,15 +292,15 @@ def from_orm(cls, obj, owner_email=None): end_time=obj.end_time, type=obj.type, species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], - comments=obj.comments, + comments=obj.comments or "", id=obj.id, owner_email=owner_email, # Include owner_email in the schema ) class UpdateSequenceAnnotationSchema(Schema): - start_time: int = None - end_time: int = None + start_time: float = None + end_time: float = None type: str | None = None comments: str | None = None diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 278cf1dc..425acc1d 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -653,6 +653,7 @@ export interface ConfigurationSettings { display_pulse_annotations: boolean; display_sequence_annotations: boolean; run_inference_on_upload: boolean; + create_pulse_annotations_from_batbot: boolean; spectrogram_x_stretch: number; spectrogram_view: "compressed" | "uncompressed"; is_admin?: boolean; diff --git a/client/src/components/AnnotationList.vue b/client/src/components/AnnotationList.vue index d235d20d..496ea739 100644 --- a/client/src/components/AnnotationList.vue +++ b/client/src/components/AnnotationList.vue @@ -15,6 +15,7 @@ import type { SpectrogramSequenceAnnotation, } from "../api/api"; import RecordingAnnotations from "./RecordingAnnotations.vue"; +import { formatSignificantDigits } from "@use/useUtils"; export default defineComponent({ name: "AnnotationList", components: { @@ -107,6 +108,7 @@ export default defineComponent({ annotationState, annotations, creationType, + formatSignificantDigits, sequenceAnnotations, selectedId, selectedType, @@ -191,7 +193,11 @@ export default defineComponent({ > ({{ annotation.end_time - annotation.start_time }}ms)({{ + formatSignificantDigits( + annotation.end_time - annotation.start_time, + ) + }}ms) diff --git a/client/src/components/PulseMetadataTooltip.vue b/client/src/components/PulseMetadataTooltip.vue index 09a7c24f..1d7ef05c 100644 --- a/client/src/components/PulseMetadataTooltip.vue +++ b/client/src/components/PulseMetadataTooltip.vue @@ -1,6 +1,7 @@ @@ -113,7 +114,7 @@ export default defineComponent({ -->