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/management/commands/copy_batbot_pulse_annotations.py b/bats_ai/core/management/commands/copy_batbot_pulse_annotations.py new file mode 100644 index 00000000..311a25a0 --- /dev/null +++ b/bats_ai/core/management/commands/copy_batbot_pulse_annotations.py @@ -0,0 +1,57 @@ +""" +Management command to duplicate the batbot model's pulse annotations for a user. + +Useful for generating training data to improve Batbot's ability to detect pulses. +""" + +from __future__ import annotations + +import logging + +from django.contrib.auth.models import User +from django.core.management.base import CommandError +import djclick as click + +from bats_ai.core.models import Annotations +from bats_ai.core.utils.batbot_annotations import BATBOT_ANNOTATION_MODEL + +logger = logging.getLogger(__name__) + + +@click.command() +@click.argument("username") +def copy_batbot_pulse_annotations(username: str): + logger.info("Finding user with username %s...", username) + new_owner = User.objects.filter(username=username).first() + if not new_owner: + raise CommandError(f"No user found with username {username}") + + logger.info("Finding all pulse annotations created by batbot...") + batbot_pulse_annotations = list( + Annotations.objects.filter(model=BATBOT_ANNOTATION_MODEL).prefetch_related("species") + ) + + copies = [ + Annotations( + recording=original.recording, + owner=new_owner, + start_time=original.start_time, + end_time=original.end_time, + low_freq=original.low_freq, + high_freq=original.high_freq, + type=original.type, + comments="Copy of batbot pulse annotation", + model="", + confidence=original.confidence, + ) + for original in batbot_pulse_annotations + ] + + logger.info("Saving %d new annotations...", len(copies)) + created = Annotations.objects.bulk_create(copies) + + logger.info("Copying species info...") + for original, copy in zip(batbot_pulse_annotations, created, strict=True): + copy.species.set(original.species.all()) + + logger.info("Done") diff --git a/bats_ai/core/migrations/0042_configuration_create_pulse_annotations_from_batbot.py b/bats_ai/core/migrations/0042_configuration_create_pulse_annotations_from_batbot.py new file mode 100644 index 00000000..49dea5b2 --- /dev/null +++ b/bats_ai/core/migrations/0042_configuration_create_pulse_annotations_from_batbot.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.7 on 2026-08-10 18:29 + +from __future__ import annotations + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more"), + ] + + 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..0a84525f 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -78,9 +78,9 @@ def recording_compute_spectrogram(self, recording_id: int): # noqa: C901, PLR09 content_type=ContentType.objects.get_for_model(spectrogram), object_id=spectrogram.id, index=idx, + type="spectrogram", defaults={ "image_file": File(f, name=os.path.basename(img_path)), - "type": "spectrogram", }, ) for idx, img_path in enumerate(results["normal"].get("waveplot_paths", [])): @@ -90,9 +90,9 @@ def recording_compute_spectrogram(self, recording_id: int): # noqa: C901, PLR09 content_type=ContentType.objects.get_for_model(spectrogram), object_id=spectrogram.id, index=idx, + type="waveform_uncompressed", defaults={ "image_file": File(buf, name=f"{base}.png"), - "type": "waveform_uncompressed", }, ) # Create or get CompressedSpectrogram @@ -116,9 +116,9 @@ def recording_compute_spectrogram(self, recording_id: int): # noqa: C901, PLR09 content_type=ContentType.objects.get_for_model(compressed_obj), object_id=compressed_obj.id, index=idx, + type="compressed", defaults={ "image_file": File(f, name=os.path.basename(img_path)), - "type": "compressed", }, ) @@ -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..9607d12c 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -178,15 +178,16 @@ 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 + model: str | None = None @classmethod def from_orm(cls, obj: Annotations, owner_email=None): @@ -196,18 +197,19 @@ 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 + model=obj.model, ) 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 +280,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 +294,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 50a2b1aa..7ad6ed76 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -66,6 +66,7 @@ export interface SpectrogramAnnotation { comments?: string; type?: string; owner_email?: string; + model?: string; } export interface SpectrogramSequenceAnnotation { @@ -643,6 +644,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({ -->