Skip to content

Commit 59275a8

Browse files
Batbot metadata drawing (#327)
* initial testing and integration * batbot metadata parser * batbot spectrogram generation * remove old spectrogram generation code * swap back to using the github installation for batbot * use temp branch for start/stop fixes * increase accuracy for spectrograms and annotations * thumbnail centering fixes * add noise filter * contour testing * contours backend * contour support * update batbot * fix contour width calculations * contour opacity settings * contour testing * fix NABat spectrogram generation * client linting * removing integration notes * use masks for contours * save mask images along compressed spectrograms * contour and mask UI * batbot metadata graphing * reconfigure pulseMetadata, add labels * Update client/src/components/TransparencyFilterControl.vue Co-authored-by: Michael Nagler <mike.nagler@kitware.com> * reverting float to ints for pixel fields * remove uneeded dependencies * import GRTS updated for sciencebase.gov downtime * add batbot issue for ml model integration * main merge migration update * swap to port 8080 for client based on main's client redirect port * remaking migrations * migrations * remove svgwrite depedency * rename extract_contours script * client side fixes to contour toggling * update pulse metadata if the compute spectrogram is run again * remove vetting details print * remove duplicate characteristic frequency plotting * make contours optional for pulseMetadata * update migrations * fix v-if, fix type for key points, nabat update or create * swap to batbot main branch * Update client/src/use/useState.ts Co-authored-by: Michael Nagler <mike.nagler@kitware.com> * Update client/src/views/Spectrogram.vue Co-authored-by: Michael Nagler <mike.nagler@kitware.com> * addressing comments * contour layer ordering * move pulse metadata to it's own usePulseMetadata singleton composable * remove deep watching from the pulseMetadata settings * swap to baseTextLayer base for pulsemetadata and use textScaling for text locations * hover to open contours * positioning of metadata text labels * swap to pypi for batbot installation * installing tasks for celery container * revert command structure and use uv run --extra * modify the celery command and cache volume name * share cache among the containers --------- Co-authored-by: Michael Nagler <mike.nagler@kitware.com>
1 parent abeb987 commit 59275a8

20 files changed

+1051
-44
lines changed

bats_ai/core/admin/pulse_metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55

66
@admin.register(PulseMetadata)
77
class PulseMetadataAdmin(admin.ModelAdmin):
8-
list_display = ('recording', 'index', 'bounding_box')
8+
list_display = ('recording', 'index', 'bounding_box', 'curve', 'char_freq', 'knee', 'heel')
99
list_select_related = True
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 4.2.23 on 2026-02-03 19:43
2+
3+
import django.contrib.gis.db.models.fields
4+
from django.db import migrations
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
('core', '0028_alter_spectrogramimage_type_pulsemetadata'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='pulsemetadata',
15+
name='char_freq',
16+
field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326),
17+
),
18+
migrations.AddField(
19+
model_name='pulsemetadata',
20+
name='curve',
21+
field=django.contrib.gis.db.models.fields.LineStringField(
22+
blank=True, null=True, srid=4326
23+
),
24+
),
25+
migrations.AddField(
26+
model_name='pulsemetadata',
27+
name='heel',
28+
field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326),
29+
),
30+
migrations.AddField(
31+
model_name='pulsemetadata',
32+
name='knee',
33+
field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326),
34+
),
35+
]

bats_ai/core/models/pulse_metadata.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ class PulseMetadata(models.Model):
88
index = models.IntegerField(null=False, blank=False)
99
bounding_box = models.PolygonField(null=False, blank=False)
1010
contours = models.JSONField(null=True, blank=True)
11-
# TODO: Add in metadata from batbot
11+
curve = models.LineStringField(null=True, blank=True)
12+
char_freq = models.PointField(null=True, blank=True)
13+
knee = models.PointField(null=True, blank=True)
14+
heel = models.PointField(null=True, blank=True)

bats_ai/core/tasks/nabat/tasks.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
from pathlib import Path
33
import tempfile
44

5+
from django.contrib.gis.geos import LineString, Point, Polygon
56
import requests
67

7-
from bats_ai.core.models import ProcessingTask
8+
from bats_ai.core.models import ProcessingTask, PulseMetadata
89
from bats_ai.core.models.nabat import NABatRecording
910
from bats_ai.utils.spectrogram_utils import (
1011
generate_nabat_compressed_spectrogram,
@@ -54,7 +55,51 @@ def generate_spectrograms(
5455

5556
compressed = results['compressed']
5657

57-
generate_nabat_compressed_spectrogram(nabat_recording, spectrogram, compressed)
58+
compressed_obj = generate_nabat_compressed_spectrogram(
59+
nabat_recording, spectrogram, compressed
60+
)
61+
segment_index_map = {}
62+
for segment in compressed['contours']['segments']:
63+
pulse_metadata_obj, _ = PulseMetadata.objects.get_or_create(
64+
recording=compressed_obj.recording,
65+
index=segment['segment_index'],
66+
defaults={
67+
'contours': segment['contours'],
68+
'bounding_box': Polygon(
69+
(
70+
(segment['start_ms'], segment['freq_max']),
71+
(segment['stop_ms'], segment['freq_max']),
72+
(segment['stop_ms'], segment['freq_min']),
73+
(segment['start_ms'], segment['freq_min']),
74+
(segment['start_ms'], segment['freq_max']),
75+
)
76+
),
77+
},
78+
)
79+
segment_index_map[segment['segment_index']] = pulse_metadata_obj
80+
for segment in compressed['segments']:
81+
if segment['segment_index'] not in segment_index_map:
82+
PulseMetadata.objects.update_or_create(
83+
recording=compressed_obj.recording,
84+
index=segment['segment_index'],
85+
defaults={
86+
'curve': LineString([Point(x[1], x[0]) for x in segment['curve_hz_ms']]),
87+
'char_freq': Point(segment['char_freq_ms'], segment['char_freq_hz']),
88+
'knee': Point(segment['knee_ms'], segment['knee_hz']),
89+
'heel': Point(segment['heel_ms'], segment['heel_hz']),
90+
},
91+
)
92+
else:
93+
pulse_metadata_obj = segment_index_map[segment['segment_index']]
94+
pulse_metadata_obj.curve = LineString(
95+
[Point(x[1], x[0]) for x in segment['curve_hz_ms']]
96+
)
97+
pulse_metadata_obj.char_freq = Point(
98+
segment['char_freq_ms'], segment['char_freq_hz']
99+
)
100+
pulse_metadata_obj.knee = Point(segment['knee_ms'], segment['knee_hz'])
101+
pulse_metadata_obj.heel = Point(segment['heel_ms'], segment['heel_hz'])
102+
pulse_metadata_obj.save()
58103

59104
processing_task.status = ProcessingTask.Status.COMPLETE
60105
processing_task.save()

bats_ai/core/tasks/tasks.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import tempfile
55

66
from django.contrib.contenttypes.models import ContentType
7-
from django.contrib.gis.geos import Polygon
7+
from django.contrib.gis.geos import LineString, Point, Polygon
88
from django.core.files import File
99

1010
from bats_ai.celery import app
@@ -101,8 +101,9 @@ def recording_compute_spectrogram(recording_id: int):
101101
)
102102

103103
# Create SpectrogramContour objects for each segment
104-
for segment in results['segments']['segments']:
105-
PulseMetadata.objects.update_or_create(
104+
segment_index_map = {}
105+
for segment in compressed['contours']['segments']:
106+
pulse_metadata_obj, _ = PulseMetadata.objects.update_or_create(
106107
recording=compressed_obj.recording,
107108
index=segment['segment_index'],
108109
defaults={
@@ -118,5 +119,29 @@ def recording_compute_spectrogram(recording_id: int):
118119
),
119120
},
120121
)
122+
segment_index_map[segment['segment_index']] = pulse_metadata_obj
123+
for segment in compressed['segments']:
124+
if segment['segment_index'] not in segment_index_map:
125+
PulseMetadata.objects.update_or_create(
126+
recording=compressed_obj.recording,
127+
index=segment['segment_index'],
128+
defaults={
129+
'curve': LineString([Point(x[1], x[0]) for x in segment['curve_hz_ms']]),
130+
'char_freq': Point(segment['char_freq_ms'], segment['char_freq_hz']),
131+
'knee': Point(segment['knee_ms'], segment['knee_hz']),
132+
'heel': Point(segment['heel_ms'], segment['heel_hz']),
133+
},
134+
)
135+
else:
136+
pulse_metadata_obj = segment_index_map[segment['segment_index']]
137+
pulse_metadata_obj.curve = LineString(
138+
[Point(x[1], x[0]) for x in segment['curve_hz_ms']]
139+
)
140+
pulse_metadata_obj.char_freq = Point(
141+
segment['char_freq_ms'], segment['char_freq_hz']
142+
)
143+
pulse_metadata_obj.knee = Point(segment['knee_ms'], segment['knee_hz'])
144+
pulse_metadata_obj.heel = Point(segment['heel_ms'], segment['heel_hz'])
145+
pulse_metadata_obj.save()
121146

122147
return {'spectrogram_id': spectrogram.id, 'compressed_id': compressed_obj.id}

bats_ai/core/utils/batbot_metadata.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from contextlib import contextmanager
22
import json
3+
import logging
34
import os
45
from pathlib import Path
56
from typing import Any, TypedDict
@@ -15,6 +16,8 @@
1516

1617
from .contour_utils import process_spectrogram_assets_for_contours
1718

19+
logger = logging.getLogger(__name__)
20+
1821

1922
class SpectrogramMetadata(BaseModel):
2023
"""Metadata about the spectrogram."""
@@ -261,6 +264,17 @@ class SpectrogramContourSegment(TypedDict):
261264
stop_ms: float
262265

263266

267+
class BatBotMetadataCurve(TypedDict):
268+
segment_index: int
269+
curve_hz_ms: list[float]
270+
char_freq_ms: float
271+
char_freq_hz: float
272+
knee_ms: float
273+
knee_hz: float
274+
heel_ms: float
275+
heel_hz: float
276+
277+
264278
class SpectrogramContours(TypedDict):
265279
segments: list[SpectrogramContourSegment]
266280
total_segments: int
@@ -272,7 +286,7 @@ class SpectrogramAssets(TypedDict):
272286
freq_max: int
273287
normal: SpectrogramAssetResult
274288
compressed: SpectrogramCompressedAssetResult
275-
segments: SpectrogramContours | None
289+
contours: SpectrogramContours | None
276290

277291

278292
@contextmanager
@@ -285,6 +299,25 @@ def working_directory(path):
285299
os.chdir(previous)
286300

287301

302+
def convert_to_segment_data(
303+
metadata: BatbotMetadata,
304+
) -> list[BatBotMetadataCurve]:
305+
segment_data: list[BatBotMetadataCurve] = []
306+
for index, segment in enumerate(metadata.segments):
307+
segment_data_item: BatBotMetadataCurve = {
308+
'segment_index': index,
309+
'curve_hz_ms': segment.curve_hz_ms,
310+
'char_freq_ms': segment.fc_ms,
311+
'char_freq_hz': segment.fc_hz,
312+
'knee_ms': segment.hi_fc_knee_ms,
313+
'knee_hz': segment.hi_fc_knee_hz,
314+
'heel_ms': segment.lo_fc_heel_ms,
315+
'heel_hz': segment.lo_fc_heel_hz,
316+
}
317+
segment_data.append(segment_data_item)
318+
return segment_data
319+
320+
288321
def generate_spectrogram_assets(recording_path: str, output_folder: str):
289322
batbot.pipeline(recording_path, output_folder=output_folder)
290323
# There should be a .metadata.json file in the output_base directory by replacing extentions
@@ -300,6 +333,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str):
300333
metadata.frequencies.max_hz
301334

302335
compressed_metadata = convert_to_compressed_spectrogram_data(metadata)
336+
segment_curve_data = convert_to_segment_data(metadata)
303337
result: SpectrogramAssets = {
304338
'duration': metadata.duration_ms,
305339
'freq_min': metadata.frequencies.min_hz,
@@ -317,10 +351,11 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str):
317351
'widths': compressed_metadata.widths,
318352
'starts': compressed_metadata.starts,
319353
'stops': compressed_metadata.stops,
354+
'segments': segment_curve_data,
320355
},
321356
}
322357

323-
segments_data = process_spectrogram_assets_for_contours(result)
324-
result['segments'] = segments_data
358+
contour_segments_data = process_spectrogram_assets_for_contours(result)
359+
result['compressed']['contours'] = contour_segments_data
325360

326361
return result

bats_ai/core/views/recording.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ class UpdateAnnotationsSchema(Schema):
129129
id: int | None
130130

131131

132-
class PulseMetadataSchema(Schema):
132+
class PulseContourSchema(Schema):
133133
id: int | None
134134
index: int
135135
bounding_box: Any
@@ -145,6 +145,36 @@ def from_orm(cls, obj: PulseMetadata):
145145
)
146146

147147

148+
class PulseMetadataSchema(Schema):
149+
id: int | None
150+
index: int
151+
curve: list[list[float]] | None = None # list of [time, frequency]
152+
char_freq: list[float] | None = None # point [time, frequency]
153+
knee: list[float] | None = None # point [time, frequency]
154+
heel: list[float] | None = None # point [time, frequency]
155+
156+
@classmethod
157+
def from_orm(cls, obj: PulseMetadata):
158+
def point_to_list(pt):
159+
if pt is None:
160+
return None
161+
return [pt.x, pt.y]
162+
163+
def linestring_to_list(ls):
164+
if ls is None:
165+
return None
166+
return [[c[0], c[1]] for c in ls.coords]
167+
168+
return cls(
169+
id=obj.id,
170+
index=obj.index,
171+
curve=linestring_to_list(obj.curve),
172+
char_freq=point_to_list(obj.char_freq),
173+
knee=point_to_list(obj.knee),
174+
heel=point_to_list(obj.heel),
175+
)
176+
177+
148178
@router.post('/')
149179
def create_recording(
150180
request: HttpRequest,
@@ -559,6 +589,25 @@ def get_annotations(request: HttpRequest, id: int):
559589
return {'error': 'Recording not found'}
560590

561591

592+
@router.get('/{id}/pulse_contours')
593+
def get_pulse_contours(request: HttpRequest, id: int):
594+
try:
595+
recording = Recording.objects.get(pk=id)
596+
if recording.owner == request.user or recording.public:
597+
computed_pulse_annotation_qs = PulseMetadata.objects.filter(
598+
recording=recording
599+
).order_by('index')
600+
return [
601+
PulseContourSchema.from_orm(pulse) for pulse in computed_pulse_annotation_qs.all()
602+
]
603+
else:
604+
return {
605+
'error': 'Permission denied. You do not own this recording, and it is not public.'
606+
}
607+
except Recording.DoesNotExist:
608+
return {'error': 'Recording not found'}
609+
610+
562611
@router.get('/{id}/pulse_data')
563612
def get_pulse_data(request: HttpRequest, id: int):
564613
try:

client/src/api/api.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -574,14 +574,28 @@ export interface Contour {
574574
index: number;
575575
}
576576

577-
export interface ComputedPulseAnnotation {
577+
export interface ComputedPulseContour {
578578
id: number;
579579
index: number;
580580
contours: Contour[];
581581
}
582582

583-
async function getComputedPulseAnnotations(recordingId: number) {
584-
const result = await axiosInstance.get<ComputedPulseAnnotation[]>(`/recording/${recordingId}/pulse_data`);
583+
async function getComputedPulseContour(recordingId: number) {
584+
const result = await axiosInstance.get<ComputedPulseContour[]>(`/recording/${recordingId}/pulse_contours`);
585+
return result.data;
586+
}
587+
588+
export interface PulseMetadata {
589+
id: number;
590+
index: number;
591+
curve: number[][] | null; // list of [time, frequency]
592+
char_freq: number[] | null; // point [time, frequency]
593+
knee: number[] | null; // point [time, frequency]
594+
heel: number[] | null; // point [time, frequency]
595+
}
596+
597+
async function getPulseMetadata(recordingId: number) {
598+
const result = await axiosInstance.get<PulseMetadata[]>(`/recording/${recordingId}/pulse_data`);
585599
return result.data;
586600
}
587601

@@ -622,7 +636,8 @@ export {
622636
getFileAnnotationDetails,
623637
getExportStatus,
624638
getRecordingTags,
625-
getComputedPulseAnnotations,
639+
getComputedPulseContour,
640+
getPulseMetadata,
626641
getCurrentUser,
627642
getVettingDetailsForUser,
628643
createOrUpdateVettingDetailsForUser,

0 commit comments

Comments
 (0)