From a38e23092a42744d08cfbec5af3224bc869c1800 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 09:31:04 -0400 Subject: [PATCH 1/4] add configuration setting for creating pulse annotations --- bats_ai/core/admin/configuration.py | 1 + bats_ai/core/models/configuration.py | 1 + bats_ai/core/views/configuration.py | 4 +++- client/src/api/api.ts | 1 + client/src/use/useState.ts | 1 + client/src/views/Admin.vue | 13 +++++++++++++ 6 files changed, 20 insertions(+), 1 deletion(-) 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/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/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/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/use/useState.ts b/client/src/use/useState.ts index 220840b7..3e8909c5 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -70,6 +70,7 @@ const configuration: Ref = ref({ spectrogram_view: "compressed", spectrogram_x_stretch: 2.5, run_inference_on_upload: true, + create_pulse_annotations_from_batbot: false, default_color_scheme: "inferno", default_spectrogram_background_color: "rgb(0, 0, 0)", is_admin: false, diff --git a/client/src/views/Admin.vue b/client/src/views/Admin.vue index 70136982..6e8c8ccc 100644 --- a/client/src/views/Admin.vue +++ b/client/src/views/Admin.vue @@ -28,6 +28,8 @@ export default defineComponent({ displaySequenceAnnotations: configuration.value.display_sequence_annotations, runInferenceOnUpload: configuration.value.run_inference_on_upload, + createPulseAnnotationsFromBatbot: + configuration.value.create_pulse_annotations_from_batbot, spectrogramXStretch: configuration.value.spectrogram_x_stretch, spectrogramView: configuration.value.spectrogram_view, defaultColorScheme: configuration.value.default_color_scheme, @@ -54,6 +56,8 @@ export default defineComponent({ configuration.value.display_sequence_annotations; settings.runInferenceOnUpload = configuration.value.run_inference_on_upload; + settings.createPulseAnnotationsFromBatbot = + configuration.value.create_pulse_annotations_from_batbot; settings.spectrogramXStretch = configuration.value.spectrogram_x_stretch; settings.defaultColorScheme = configuration.value.default_color_scheme; settings.defaultBackgroundColor = @@ -74,6 +78,8 @@ export default defineComponent({ display_pulse_annotations: settings.displayPulseAnnotations, display_sequence_annotations: settings.displaySequenceAnnotations, run_inference_on_upload: settings.runInferenceOnUpload, + create_pulse_annotations_from_batbot: + settings.createPulseAnnotationsFromBatbot, spectrogram_x_stretch: settings.spectrogramXStretch, default_color_scheme: settings.defaultColorScheme, default_spectrogram_background_color: settings.defaultBackgroundColor, @@ -148,6 +154,13 @@ export default defineComponent({ label="Run Inference on Upload" /> + + +
Stretch compressed spectrogram
From 81f0e68fc772352e2698b61a5d197d941b4e4519 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 09:31:19 -0400 Subject: [PATCH 2/4] migration for pulses --- ...ion_create_pulse_annotations_from_batbot.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py 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), + ), + ] From 7529cc1ff1c8bbd86510e5d4ee7fbef35c8f3e4e Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 09:31:36 -0400 Subject: [PATCH 3/4] extract pulses and add to recording data --- bats_ai/core/tasks/tasks.py | 9 ++++ bats_ai/core/utils/batbot_annotations.py | 69 ++++++++++++++++++++++++ bats_ai/core/views/recording.py | 32 +++++------ 3 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 bats_ai/core/utils/batbot_annotations.py 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/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 From 05bac54c8d17ff866ee014ec14c528d035a3cc92 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 10:11:56 -0400 Subject: [PATCH 4/4] recenter on select, format precision for times --- client/src/components/AnnotationList.vue | 8 ++++++- .../src/components/PulseMetadataTooltip.vue | 5 +++-- client/src/components/SpectrogramViewer.vue | 8 ++++--- client/src/components/geoJS/geoJSUtils.ts | 22 ++++++++++++++----- .../geoJS/layers/pulseMetadataLayer.ts | 3 ++- .../src/components/geoJS/layers/timeLayer.ts | 3 ++- client/src/use/useUtils.ts | 8 +++++++ client/src/views/Admin.vue | 4 +++- 8 files changed, 47 insertions(+), 14 deletions(-) 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({ -->
Duration - {{ data.durationMs.toFixed(1) }} ms + {{ formatSignificantDigits(data.durationMs) }} ms
Fₘᵢₙ diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index a7e74f5c..3d56f33c 100644 --- a/client/src/components/SpectrogramViewer.vue +++ b/client/src/components/SpectrogramViewer.vue @@ -419,10 +419,12 @@ export default defineComponent({ found, props.spectroInfo, selectedType.value, + scaledWidth.value, + scaledHeight.value, ); - const bounds = geoJS.getGeoViewer().value.bounds(); - if (x < bounds.left || x > bounds.right) { - geoJS.getGeoViewer().value.center({ x, y }); + const viewer = geoJS.getGeoViewer().value; + if (viewer && x >= 0 && y >= 0) { + viewer.center({ x, y }); } } }); diff --git a/client/src/components/geoJS/geoJSUtils.ts b/client/src/components/geoJS/geoJSUtils.ts index 6a70a2e2..60562dc1 100644 --- a/client/src/components/geoJS/geoJSUtils.ts +++ b/client/src/components/geoJS/geoJSUtils.ts @@ -914,18 +914,30 @@ function spectroToCenter( annotation: SpectrogramAnnotation | SpectrogramSequenceAnnotation, spectroInfo: SpectroInfo, type: "sequence" | "pulse", + scaledWidth = 0, + scaledHeight = 0, ) { if (type === "pulse") { - const geoJSON = spectroToGeoJSon( - annotation as SpectrogramAnnotation, - spectroInfo, - ); - return findPolygonCenter(geoJSON); + const pulse = annotation as SpectrogramAnnotation; + const centerTime = (pulse.start_time + pulse.end_time) / 2; + const x = spectroTimeToX(centerTime, spectroInfo, scaledWidth); + const adjustedHeight = + scaledHeight > spectroInfo.height ? scaledHeight : spectroInfo.height; + const centerFreq = (pulse.low_freq + pulse.high_freq) / 2; + const heightScale = + adjustedHeight / (spectroInfo.high_freq - spectroInfo.low_freq); + const y = + adjustedHeight - (centerFreq - spectroInfo.low_freq) * heightScale; + return [x, y]; } if (type === "sequence") { const geoJSON = spectroSequenceToGeoJSon( annotation as SpectrogramSequenceAnnotation, spectroInfo, + 0, + 10, + scaledWidth, + scaledHeight, ); return findPolygonCenter(geoJSON); } diff --git a/client/src/components/geoJS/layers/pulseMetadataLayer.ts b/client/src/components/geoJS/layers/pulseMetadataLayer.ts index 41cdf611..c3cb179c 100644 --- a/client/src/components/geoJS/layers/pulseMetadataLayer.ts +++ b/client/src/components/geoJS/layers/pulseMetadataLayer.ts @@ -4,6 +4,7 @@ import type { PulseMetadata } from "@api/api"; import type { LayerStyle, LineData, TextData } from "./types"; import BaseTextLayer from "./baseTextLayer"; import type { PulseMetadataLabelsMode } from "@use/usePulseMetadata"; +import { formatSignificantDigits } from "@use/useUtils"; /** Point data for char_freq, knee, heel with pixel coords and label. */ interface PulsePointData { @@ -398,7 +399,7 @@ export default class PulseMetadataLayer extends BaseTextLayer { const durationMidX = (bottomLeft.x + bottomRight.x) / 2; pulseText.push({ - text: `${durationMs.toFixed(1)} ms`, + text: `${formatSignificantDigits(durationMs)} ms`, x: durationMidX, y: bottomLeft.y + labelOffset, offsetX: 0, diff --git a/client/src/components/geoJS/layers/timeLayer.ts b/client/src/components/geoJS/layers/timeLayer.ts index 4e5e3e55..9de6e318 100644 --- a/client/src/components/geoJS/layers/timeLayer.ts +++ b/client/src/components/geoJS/layers/timeLayer.ts @@ -7,6 +7,7 @@ import { spectroSequenceToGeoJSon, spectroToGeoJSon, } from "../geoJSUtils"; +import { formatSignificantDigits } from "@use/useUtils"; import BaseTextLayer from "./baseTextLayer"; import type { LayerStyle } from "./types"; @@ -242,7 +243,7 @@ export default class TimeLayer extends BaseTextLayer { const ypos = (ymax + ymin) / 2.0; // Now we need to create the text Labels this.textData.push({ - text: `${end_time - start_time}ₘₛ`, + text: `${formatSignificantDigits(end_time - start_time)}ₘₛ`, x: xpos, y: ypos + lineDist, }); diff --git a/client/src/use/useUtils.ts b/client/src/use/useUtils.ts index 6a3efa99..40b3c679 100644 --- a/client/src/use/useUtils.ts +++ b/client/src/use/useUtils.ts @@ -30,6 +30,13 @@ function extractDateTimeComponents(dateTimeString: string) { return { date: dateString, time: timeString }; } +/** Format a numeric value to the given number of significant digits. */ +function formatSignificantDigits(value: number, significantDigits = 3): string { + if (!Number.isFinite(value)) return String(value); + if (value === 0) return "0"; + return String(Number(value.toPrecision(significantDigits))); +} + function getImageDimensions( images: HTMLImageElement[], fallback: { width: number; height: number } = { width: 0, height: 0 }, @@ -96,6 +103,7 @@ function parseRecordingFilename( export { DEFAULT_SAMPLE_FRAME_ID, + formatSignificantDigits, getCurrentTime, extractDateTimeComponents, getImageDimensions, diff --git a/client/src/views/Admin.vue b/client/src/views/Admin.vue index 6e8c8ccc..97c9a9d9 100644 --- a/client/src/views/Admin.vue +++ b/client/src/views/Admin.vue @@ -157,7 +157,9 @@ export default defineComponent({