Skip to content
Merged
28 changes: 28 additions & 0 deletions bats_ai/core/migrations/0034_alter_spectrogramimage_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 6.0.3 on 2026-03-16 19:07
from __future__ import annotations

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0033_recording_annotation_species_through"),
]

operations = [
migrations.AlterField(
model_name="spectrogramimage",
name="type",
field=models.CharField(
choices=[
("spectrogram", "Spectrogram"),
("compressed", "Compressed"),
("masks", "Masks"),
("waveform_compressed", "Waveform Compressed"),
("waveform_uncompressed", "Waveform Uncompressed"),
],
default="spectrogram",
max_length=128,
),
),
]
6 changes: 6 additions & 0 deletions bats_ai/core/models/compressed_spectrogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ def mask_url_list(self):
"""Ordered list of mask image URLs for this spectrogram."""
images = self.images.filter(type="masks").order_by("index")
return [default_storage.url(img.image_file.name) for img in images]

@property
def waveplot_url_list(self):
"""Ordered list of waveplot image URLs for this compressed spectrogram."""
images = self.images.filter(type="waveform_compressed").order_by("index")
return [default_storage.url(img.image_file.name) for img in images]
6 changes: 6 additions & 0 deletions bats_ai/core/models/nabat/nabat_compressed_spectrogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ def mask_url_list(self):
"""Ordered list of mask image URLs for this spectrogram."""
images = self.images.filter(type="masks").order_by("index")
return [default_storage.url(img.image_file.name) for img in images]

@property
def waveplot_url_list(self):
"""Ordered list of waveplot image URLs for this compressed spectrogram."""
images = self.images.filter(type="waveform_compressed").order_by("index")
return [default_storage.url(img.image_file.name) for img in images]
4 changes: 3 additions & 1 deletion bats_ai/core/models/spectrogram_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ class SpectrogramImage(models.Model):
("spectrogram", "Spectrogram"),
("compressed", "Compressed"),
("masks", "Masks"),
("waveform_compressed", "Waveform Compressed"),
("waveform_uncompressed", "Waveform Uncompressed"),
]
content_object = GenericForeignKey("content_type", "object_id")

content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
type = models.CharField(
max_length=20,
max_length=128,
choices=SPECTROGRAM_TYPE_CHOICES,
default="spectrogram",
)
Expand Down
27 changes: 26 additions & 1 deletion bats_ai/core/tasks/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Spectrogram,
SpectrogramImage,
)
from bats_ai.core.utils.image_utils import waveplot_to_grayscale_transparent

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("NABatDataRetrieval")
Expand Down Expand Up @@ -81,7 +82,18 @@ def recording_compute_spectrogram(self, recording_id: int):
"type": "spectrogram",
},
)

for idx, img_path in enumerate(results["normal"].get("waveplot_paths", [])):
buf = waveplot_to_grayscale_transparent(img_path)
base = os.path.splitext(os.path.basename(img_path))[0]
SpectrogramImage.objects.get_or_create(
content_type=ContentType.objects.get_for_model(spectrogram),
object_id=spectrogram.id,
index=idx,
defaults={
"image_file": File(buf, name=f"{base}.png"),
"type": "waveform_uncompressed",
},
)
# Create or get CompressedSpectrogram
compressed = results["compressed"]
compressed_obj, _ = CompressedSpectrogram.objects.get_or_create(
Expand Down Expand Up @@ -122,6 +134,19 @@ def recording_compute_spectrogram(self, recording_id: int):
},
)

# Save waveform images that are created during compression
for idx, waveplot_path in enumerate(compressed.get("waveplot_paths", [])):
buf = waveplot_to_grayscale_transparent(waveplot_path)
base = os.path.splitext(os.path.basename(waveplot_path))[0]
SpectrogramImage.objects.get_or_create(
content_type=ContentType.objects.get_for_model(compressed_obj),
object_id=compressed_obj.id,
index=idx,
type="waveform_compressed",
defaults={
"image_file": File(buf, name=f"{base}.png"),
},
)
# Create SpectrogramContour objects for each segment
segment_index_map = {}
for segment in compressed["contours"]["segments"]:
Expand Down
9 changes: 6 additions & 3 deletions bats_ai/core/utils/batbot_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class SpectrogramMetadata(BaseModel):
uncompressed_path: list[str] = Field(alias="uncompressed.path")
compressed_path: list[str] = Field(alias="compressed.path")
mask_path: list[str] = Field(alias="mask.path")
waveplot_path: list[str] = Field(alias="waveplot.path")
waveplot_compressed_path: list[str] = Field(alias="waveplot.compressed.path")


class UncompressedSize(BaseModel):
Expand Down Expand Up @@ -104,7 +106,6 @@ class Segment(BaseModel):
slope_lo_avg_khz_per_ms: float | None = Field(None, alias="slope/lo[avg].khz/ms")
slope_box_khz_per_ms: float | None = Field(None, alias="slope[box].khz/ms")
slope_hi_box_khz_per_ms: float | None = Field(None, alias="slope/hi[box].khz/ms")
slope_mid_box_khz_per_ms: float | None = Field(None, alias="slope/mid[box].khz/ms")
slope_lo_box_khz_per_ms: float | None = Field(None, alias="slope/lo[box].khz/ms")

@field_validator("curve_hz_ms", mode="before")
Expand Down Expand Up @@ -279,7 +280,6 @@ class BatBotSlopes(TypedDict, total=False):
slope_lo_avg_khz_per_ms: float | None
slope_box_khz_per_ms: float | None
slope_hi_box_khz_per_ms: float | None
slope_mid_box_khz_per_ms: float | None
slope_lo_box_khz_per_ms: float | None


Expand All @@ -294,7 +294,6 @@ class BatBotSlopes(TypedDict, total=False):
"slope_lo_avg_khz_per_ms",
"slope_box_khz_per_ms",
"slope_hi_box_khz_per_ms",
"slope_mid_box_khz_per_ms",
"slope_lo_box_khz_per_ms",
)

Expand Down Expand Up @@ -371,6 +370,8 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str):
uncompressed_paths = metadata.spectrogram.uncompressed_path
compressed_paths = metadata.spectrogram.compressed_path
mask_paths = metadata.spectrogram.mask_path
waveplot_paths = metadata.spectrogram.waveplot_path
compressed_waveplot_paths = metadata.spectrogram.waveplot_compressed_path

compressed_metadata = convert_to_compressed_spectrogram_data(metadata)
segment_curve_data = convert_to_segment_data(metadata)
Expand All @@ -380,12 +381,14 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str):
"freq_max": metadata.frequencies.max_hz,
"normal": {
"paths": uncompressed_paths,
"waveplot_paths": waveplot_paths,
"width": metadata.size.uncompressed.width_px,
"height": metadata.size.uncompressed.height_px,
},
"compressed": {
"paths": compressed_paths,
"masks": mask_paths,
"waveplot_paths": compressed_waveplot_paths,
"width": metadata.size.compressed.width_px,
"height": metadata.size.compressed.height_px,
"widths": compressed_metadata.widths,
Expand Down
28 changes: 28 additions & 0 deletions bats_ai/core/utils/image_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Image processing utilities for spectrogram and waveplot assets."""

from __future__ import annotations

from io import BytesIO


def waveplot_to_grayscale_transparent(source_path: str) -> BytesIO:
"""
Convert a waveplot image to grayscale with transparent background.

Reads the image from source_path, converts it to grayscale, and makes
near-white background pixels transparent. Returns PNG bytes in a BytesIO.
"""
from PIL import Image

img = Image.open(source_path)
gray = img.convert("L")
# Treat luminance >= 200 as background (white)
threshold = 200
data = list(gray.getdata())
alpha = [0 if p >= threshold else 255 for p in data]
out = Image.new("RGBA", gray.size)
out.putdata([(p, p, p, a) for p, a in zip(data, alpha, strict=False)])
buf = BytesIO()
out.save(buf, format="PNG")
buf.seek(0)
return buf
1 change: 1 addition & 0 deletions bats_ai/core/views/nabat/nabat_recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ def get_spectrogram_compressed(
return {
"urls": compressed_spectrogram.image_url_list,
"mask_urls": compressed_spectrogram.mask_url_list,
"waveplot_urls": compressed_spectrogram.waveplot_url_list,
"spectroInfo": {
"spectroId": compressed_spectrogram.pk,
"width": compressed_spectrogram.spectrogram.width,
Expand Down
2 changes: 1 addition & 1 deletion bats_ai/core/views/recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ class PulseMetadataSlopesSchema(Schema):
slope_lo_avg_khz_per_ms: float | None
slope_box_khz_per_ms: float | None
slope_hi_box_khz_per_ms: float | None
slope_mid_box_khz_per_ms: float | None
slope_lo_box_khz_per_ms: float | None


Expand Down Expand Up @@ -810,6 +809,7 @@ def get_spectrogram_compressed(request: HttpRequest, pk: int):
spectro_data = {
"urls": compressed_spectrogram.image_url_list,
"mask_urls": compressed_spectrogram.mask_url_list,
"waveplot_urls": compressed_spectrogram.waveplot_url_list,
"filename": recording.name,
"spectroInfo": {
"spectroId": compressed_spectrogram.pk,
Expand Down
31 changes: 30 additions & 1 deletion bats_ai/utils/spectrogram_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,29 @@

import logging
import os
from typing import TypedDict
from typing import NotRequired, TypedDict

from django.contrib.contenttypes.models import ContentType
from django.core.files import File

from bats_ai.core.models import SpectrogramImage
from bats_ai.core.models.nabat import NABatCompressedSpectrogram, NABatRecording, NABatSpectrogram
from bats_ai.core.utils.image_utils import waveplot_to_grayscale_transparent

logger = logging.getLogger(__name__)


class SpectrogramAssetResult(TypedDict):
paths: list[str]
waveplot_paths: NotRequired[list[str]]
width: int
height: int


class SpectrogramCompressedAssetResult(TypedDict):
paths: list[str]
masks: list[str]
waveplot_paths: NotRequired[list[str]]
width: int
height: int
widths: list[float]
Expand Down Expand Up @@ -70,6 +73,19 @@ def generate_nabat_spectrogram(
},
)

for idx, img_path in enumerate(results["normal"].get("waveplot_paths", [])):
buf = waveplot_to_grayscale_transparent(img_path)
base = os.path.splitext(os.path.basename(img_path))[0]
SpectrogramImage.objects.get_or_create(
content_type=ContentType.objects.get_for_model(spectrogram),
object_id=spectrogram.id,
index=idx,
defaults={
"image_file": File(buf, name=f"{base}.png"),
"type": "waveform_uncompressed",
},
)

return spectrogram


Expand Down Expand Up @@ -116,4 +132,17 @@ def generate_nabat_compressed_spectrogram(
},
)

for idx, waveplot_path in enumerate(compressed_results.get("waveplot_paths", [])):
buf = waveplot_to_grayscale_transparent(waveplot_path)
base = os.path.splitext(os.path.basename(waveplot_path))[0]
SpectrogramImage.objects.get_or_create(
content_type=ContentType.objects.get_for_model(compressed_obj),
object_id=compressed_obj.id,
index=idx,
type="waveform_compressed",
defaults={
"image_file": File(buf, name=f"{base}.png"),
},
)

return compressed_obj
2 changes: 1 addition & 1 deletion client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface UpdateFileAnnotation {
export interface Spectrogram {
urls: string[];
mask_urls: string[];
waveplot_urls?: string[];
filename?: string;
annotations?: SpectrogramAnnotation[];
fileAnnotations: FileAnnotation[];
Expand Down Expand Up @@ -665,7 +666,6 @@ export interface PulseMetadataSlopes {
slope_lo_avg_khz_per_ms?: number;
slope_box_khz_per_ms?: number;
slope_hi_box_khz_per_ms?: number;
slope_mid_box_khz_per_ms?: number;
slope_lo_box_khz_per_ms?: number;
}

Expand Down
27 changes: 17 additions & 10 deletions client/src/components/PulseMetadataTooltip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,6 @@ export default defineComponent({
<span class="text-caption text-medium-emphasis mr-2">Knee</span>
<span>{{ data.kneeKhz.toFixed(1) }} kHz</span>
</div>
<div v-if="data.slopeHiAvg != null">
<span
class="text-caption ml-4 text-medium-emphasis"
>
<b>Slope Hi Avg:</b> {{ data.slopeHiAvg.toFixed(2) }} kHz/ms
</span>
</div>
<div
v-if="data.fcKhz != null"
class="d-flex align-center"
Expand All @@ -96,11 +89,25 @@ export default defineComponent({
<span class="text-caption text-medium-emphasis mr-2">Fc</span>
<span>{{ data.fcKhz.toFixed(1) }} kHz</span>
</div>
<div v-if="data.slopeTotalAvg != null">
<div v-if="data.slopeHi != null">
<span
class="text-caption text-medium-emphasis"
>
<b>Slope Hi:</b> {{ data.slopeHi.toFixed(2) }} kHz/ms
</span>
</div>
<div v-if="data.slopeTotal != null">
<span
class="text-caption text-medium-emphasis"
>
<b>Slope Total:</b>{{ data.slopeTotal.toFixed(2) }} kHz/ms
</span>
</div>
<div v-if="data.slopeLow != null">
<span
class="text-caption ml-4 text-medium-emphasis"
class="text-caption text-medium-emphasis"
>
<b>Slope Total Avg:</b>{{ data.slopeTotalAvg.toFixed(2) }} kHz/ms
<b>Slope Low:</b> {{ data.slopeLow.toFixed(2) }} kHz/ms
</span>
</div>
<!-- DISABLED HEEL FOR NOW -->
Expand Down
Loading