From 16cd953d7a5866bfc6cb8763199e3471dbf5299c Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 09:31:04 -0400 Subject: [PATCH 01/11] 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 50a2b1aa..a7ec57ad 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -643,6 +643,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 1427a73a751c95f214795717c12d988939a7e809 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 09:31:19 -0400 Subject: [PATCH 02/11] 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 1855a56716b6305960e6bf9c9de2e944e4ecc8ec Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 09:31:36 -0400 Subject: [PATCH 03/11] 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 7b4f18604a9da15a0ed561a557ddb871c4e26cef Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 11 Jun 2026 10:11:56 -0400 Subject: [PATCH 04/11] 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({ From 1898a227823c7aa72788a463c4ec8fe2a48c89c5 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Fri, 7 Aug 2026 15:26:36 -0400 Subject: [PATCH 05/11] Fix get_or_create calls --- bats_ai/core/tasks/tasks.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index 23cf63ab..4bf441ee 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -11,15 +11,8 @@ from django.core.files import File from bats_ai.celery import app -from bats_ai.core.models import ( - CompressedSpectrogram, - ProcessingTask, - ProcessingTaskType, - PulseMetadata, - Recording, - Spectrogram, - SpectrogramImage, -) +from bats_ai.core.models import (CompressedSpectrogram, ProcessingTask, ProcessingTaskType, + PulseMetadata, Recording, Spectrogram, SpectrogramImage) from bats_ai.core.utils.image_utils import waveplot_to_grayscale_transparent logging.basicConfig(level=logging.INFO) @@ -78,9 +71,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 +83,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 +109,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,9 +221,8 @@ 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, - ) + from bats_ai.core.utils.batbot_annotations import \ + create_pulse_annotations_from_batbot_segments create_pulse_annotations_from_batbot_segments( recording, From bf8a3ec61801a3a6f0e9b50a56e4e10fe2edbb4d Mon Sep 17 00:00:00 2001 From: naglepuff Date: Fri, 7 Aug 2026 17:17:21 -0400 Subject: [PATCH 06/11] Allow any users to edit "batbot" pulse annots --- bats_ai/core/views/recording.py | 30 +++++++++++++----------------- client/src/api/api.ts | 1 + 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index a2532064..376f553d 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -1,8 +1,8 @@ from __future__ import annotations -from datetime import date, datetime, time import json import logging +from datetime import date, datetime, time from typing import TYPE_CHECKING, Any, Literal from django.contrib.auth.models import User @@ -13,22 +13,13 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from ninja import File, Form, Query, Schema - # Django-Ninja accesses additional params directly, so we need to ignore the type checker. from ninja.files import UploadedFile # noqa: TC002 from ninja.pagination import RouterPaginated -from bats_ai.core.models import ( - Annotations, - CompressedSpectrogram, - PulseMetadata, - Recording, - RecordingAnnotation, - RecordingTag, - SequenceAnnotations, - Species, - Spectrogram, -) +from bats_ai.core.models import (Annotations, CompressedSpectrogram, PulseMetadata, Recording, + RecordingAnnotation, RecordingTag, SequenceAnnotations, Species, + Spectrogram) from bats_ai.core.tasks.tasks import recording_compute_spectrogram from bats_ai.core.views.recording_location import _parse_bbox, filter_recordings_by_map_bbox from bats_ai.core.views.species import SpeciesSchema @@ -187,6 +178,7 @@ class AnnotationSchema(Schema): 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): @@ -200,6 +192,7 @@ def from_orm(cls, obj: Annotations, owner_email=None): id=obj.id, type=obj.type, owner_email=owner_email, # Include owner_email in the schema + model=obj.model, ) @@ -790,7 +783,7 @@ def get_spectrogram(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email - annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model="batbot"))) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user ) @@ -863,7 +856,9 @@ def get_spectrogram_compressed(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email - annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + annotations_qs = Annotations.objects.filter( + Q(owner=request.user) | Q(model="batbot"), recording=recording, + ) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user ) @@ -893,7 +888,7 @@ def get_annotations(request: HttpRequest, pk: int): # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: # Query annotations associated with the recording that are owned by the current user - annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model="batbot"))) # Serialize the annotations using AnnotationSchema return [ @@ -1082,7 +1077,8 @@ def patch_annotation( # noqa: C901, PLR0912 # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: annotation_instance = Annotations.objects.get( - pk=annotation_pk, recording=recording, owner=request.user + (Q(owner=request.user) | Q(model="batbot")), + pk=annotation_pk, recording=recording, ) if annotation_instance is None: return {"error": "Annotation not found"} diff --git a/client/src/api/api.ts b/client/src/api/api.ts index a7ec57ad..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 { From f59785153a2700923a6e3ea6519c171b36d80dba Mon Sep 17 00:00:00 2001 From: naglepuff Date: Mon, 10 Aug 2026 12:13:11 -0400 Subject: [PATCH 07/11] Use constant value for "batbot" --- bats_ai/core/views/recording.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 376f553d..8ce54765 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -21,6 +21,7 @@ RecordingAnnotation, RecordingTag, SequenceAnnotations, Species, Spectrogram) from bats_ai.core.tasks.tasks import recording_compute_spectrogram +from bats_ai.core.utils.batbot_annotations import BATBOT_ANNOTATION_MODEL from bats_ai.core.views.recording_location import _parse_bbox, filter_recordings_by_map_bbox from bats_ai.core.views.species import SpeciesSchema @@ -783,7 +784,7 @@ def get_spectrogram(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email - annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model="batbot"))) + annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL))) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user ) @@ -857,7 +858,7 @@ def get_spectrogram_compressed(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email annotations_qs = Annotations.objects.filter( - Q(owner=request.user) | Q(model="batbot"), recording=recording, + Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL), recording=recording, ) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user @@ -888,7 +889,7 @@ def get_annotations(request: HttpRequest, pk: int): # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: # Query annotations associated with the recording that are owned by the current user - annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model="batbot"))) + annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL))) # Serialize the annotations using AnnotationSchema return [ @@ -1077,7 +1078,7 @@ def patch_annotation( # noqa: C901, PLR0912 # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: annotation_instance = Annotations.objects.get( - (Q(owner=request.user) | Q(model="batbot")), + (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)), pk=annotation_pk, recording=recording, ) if annotation_instance is None: From 7de587a0bb6646ddba73d434205781f22ae5d687 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Mon, 10 Aug 2026 12:13:39 -0400 Subject: [PATCH 08/11] Format --- bats_ai/core/tasks/tasks.py | 16 ++++++++++++---- bats_ai/core/views/recording.py | 31 +++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index 4bf441ee..0a84525f 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -11,8 +11,15 @@ from django.core.files import File from bats_ai.celery import app -from bats_ai.core.models import (CompressedSpectrogram, ProcessingTask, ProcessingTaskType, - PulseMetadata, Recording, Spectrogram, SpectrogramImage) +from bats_ai.core.models import ( + CompressedSpectrogram, + ProcessingTask, + ProcessingTaskType, + PulseMetadata, + Recording, + Spectrogram, + SpectrogramImage, +) from bats_ai.core.utils.image_utils import waveplot_to_grayscale_transparent logging.basicConfig(level=logging.INFO) @@ -221,8 +228,9 @@ 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 + from bats_ai.core.utils.batbot_annotations import ( + create_pulse_annotations_from_batbot_segments, + ) create_pulse_annotations_from_batbot_segments( recording, diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 8ce54765..d63bdca5 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -1,8 +1,8 @@ from __future__ import annotations +from datetime import date, datetime, time import json import logging -from datetime import date, datetime, time from typing import TYPE_CHECKING, Any, Literal from django.contrib.auth.models import User @@ -13,13 +13,22 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from ninja import File, Form, Query, Schema + # Django-Ninja accesses additional params directly, so we need to ignore the type checker. from ninja.files import UploadedFile # noqa: TC002 from ninja.pagination import RouterPaginated -from bats_ai.core.models import (Annotations, CompressedSpectrogram, PulseMetadata, Recording, - RecordingAnnotation, RecordingTag, SequenceAnnotations, Species, - Spectrogram) +from bats_ai.core.models import ( + Annotations, + CompressedSpectrogram, + PulseMetadata, + Recording, + RecordingAnnotation, + RecordingTag, + SequenceAnnotations, + Species, + Spectrogram, +) from bats_ai.core.tasks.tasks import recording_compute_spectrogram from bats_ai.core.utils.batbot_annotations import BATBOT_ANNOTATION_MODEL from bats_ai.core.views.recording_location import _parse_bbox, filter_recordings_by_map_bbox @@ -784,7 +793,9 @@ def get_spectrogram(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email - annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL))) + annotations_qs = Annotations.objects.filter( + Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)) + ) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user ) @@ -858,7 +869,8 @@ def get_spectrogram_compressed(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email annotations_qs = Annotations.objects.filter( - Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL), recording=recording, + Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL), + recording=recording, ) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user @@ -889,7 +901,9 @@ def get_annotations(request: HttpRequest, pk: int): # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: # Query annotations associated with the recording that are owned by the current user - annotations_qs = Annotations.objects.filter(Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL))) + annotations_qs = Annotations.objects.filter( + Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)) + ) # Serialize the annotations using AnnotationSchema return [ @@ -1079,7 +1093,8 @@ def patch_annotation( # noqa: C901, PLR0912 if recording.owner == request.user or recording.public: annotation_instance = Annotations.objects.get( (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)), - pk=annotation_pk, recording=recording, + pk=annotation_pk, + recording=recording, ) if annotation_instance is None: return {"error": "Annotation not found"} From 699e9b38c817939149c078d54692c9cd133d784c Mon Sep 17 00:00:00 2001 From: naglepuff Date: Mon, 10 Aug 2026 14:30:40 -0400 Subject: [PATCH 09/11] Regenerate migration file --- ...42_configuration_create_pulse_annotations_from_batbot.py} | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename bats_ai/core/migrations/{0041_configuration_create_pulse_annotations_from_batbot.py => 0042_configuration_create_pulse_annotations_from_batbot.py} (73%) diff --git a/bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py b/bats_ai/core/migrations/0042_configuration_create_pulse_annotations_from_batbot.py similarity index 73% rename from bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py rename to bats_ai/core/migrations/0042_configuration_create_pulse_annotations_from_batbot.py index 5b702d4c..49dea5b2 100644 --- a/bats_ai/core/migrations/0041_configuration_create_pulse_annotations_from_batbot.py +++ b/bats_ai/core/migrations/0042_configuration_create_pulse_annotations_from_batbot.py @@ -1,4 +1,5 @@ -# Generated by Django 4.2.23 on 2026-06-11 00:00 +# Generated by Django 6.0.7 on 2026-08-10 18:29 + from __future__ import annotations from django.db import migrations, models @@ -6,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ("core", "0040_alter_grtscells_id"), + ("core", "0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more"), ] operations = [ From 5188dc1d80df28bff5e24801c632e60c9b48b326 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Mon, 10 Aug 2026 15:13:43 -0400 Subject: [PATCH 10/11] Prevent non-owners from seeing batbot annotations --- bats_ai/core/views/recording.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index d63bdca5..9607d12c 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -30,7 +30,6 @@ Spectrogram, ) from bats_ai.core.tasks.tasks import recording_compute_spectrogram -from bats_ai.core.utils.batbot_annotations import BATBOT_ANNOTATION_MODEL from bats_ai.core.views.recording_location import _parse_bbox, filter_recordings_by_map_bbox from bats_ai.core.views.species import SpeciesSchema @@ -793,9 +792,7 @@ def get_spectrogram(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email - annotations_qs = Annotations.objects.filter( - Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)) - ) + annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user ) @@ -868,10 +865,7 @@ def get_spectrogram_compressed(request: HttpRequest, pk: int): spectro_data["currentUser"] = request.user.email - annotations_qs = Annotations.objects.filter( - Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL), - recording=recording, - ) + annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) sequence_annotations_qs = SequenceAnnotations.objects.filter( recording=recording, owner=request.user ) @@ -901,9 +895,7 @@ def get_annotations(request: HttpRequest, pk: int): # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: # Query annotations associated with the recording that are owned by the current user - annotations_qs = Annotations.objects.filter( - Q(recording=recording) & (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)) - ) + annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) # Serialize the annotations using AnnotationSchema return [ @@ -1092,9 +1084,7 @@ def patch_annotation( # noqa: C901, PLR0912 # Check if the user owns the recording or if the recording is public if recording.owner == request.user or recording.public: annotation_instance = Annotations.objects.get( - (Q(owner=request.user) | Q(model=BATBOT_ANNOTATION_MODEL)), - pk=annotation_pk, - recording=recording, + pk=annotation_pk, recording=recording, owner=request.user ) if annotation_instance is None: return {"error": "Annotation not found"} From 221df9f431c399bda0f8e1096aec075e82302452 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Mon, 10 Aug 2026 16:20:54 -0400 Subject: [PATCH 11/11] Add management command for copying batbot annots --- .../commands/copy_batbot_pulse_annotations.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 bats_ai/core/management/commands/copy_batbot_pulse_annotations.py 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")