Skip to content
1 change: 1 addition & 0 deletions bats_ai/core/admin/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
57 changes: 57 additions & 0 deletions bats_ai/core/management/commands/copy_batbot_pulse_annotations.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions bats_ai/core/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions bats_ai/core/tasks/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])):
Expand All @@ -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
Expand All @@ -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",
},
)

Expand Down Expand Up @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions bats_ai/core/utils/batbot_annotations.py
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
naglepuff marked this conversation as resolved.

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
4 changes: 3 additions & 1 deletion bats_ai/core/views/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down
34 changes: 18 additions & 16 deletions bats_ai/core/views/recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface SpectrogramAnnotation {
comments?: string;
type?: string;
owner_email?: string;
model?: string;
}

export interface SpectrogramSequenceAnnotation {
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion client/src/components/AnnotationList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -107,6 +108,7 @@ export default defineComponent({
annotationState,
annotations,
creationType,
formatSignificantDigits,
sequenceAnnotations,
selectedId,
selectedType,
Expand Down Expand Up @@ -191,7 +193,11 @@ export default defineComponent({
>
<span class="pl-2"
><b
>({{ annotation.end_time - annotation.start_time }}ms)</b
>({{
formatSignificantDigits(
annotation.end_time - annotation.start_time,
)
}}ms)</b
></span
>
</v-col>
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/PulseMetadataTooltip.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { defineComponent, nextTick, type PropType, ref, watch } from "vue";
import type { PulseMetadataTooltipData } from "./geoJS/layers/pulseMetadataLayer";
import { formatSignificantDigits } from "@use/useUtils";

const MIN_WIDTH = 180;

Expand Down Expand Up @@ -41,7 +42,7 @@ export default defineComponent({
{ immediate: true },
);

return { cardRef, clampedLeft };
return { cardRef, clampedLeft, formatSignificantDigits };
},
});
</script>
Expand Down Expand Up @@ -113,7 +114,7 @@ export default defineComponent({
</div> -->
<div class="d-flex align-center">
<span class="text-caption text-medium-emphasis mr-2">Duration</span>
<span>{{ data.durationMs.toFixed(1) }} ms</span>
<span>{{ formatSignificantDigits(data.durationMs) }} ms</span>
</div>
<div class="d-flex align-center">
<span class="text-caption text-medium-emphasis mr-2">Fₘᵢₙ</span>
Expand Down
Loading