diff --git a/bats_ai/core/migrations/0034_alter_spectrogramimage_type.py b/bats_ai/core/migrations/0034_alter_spectrogramimage_type.py new file mode 100644 index 00000000..f137df04 --- /dev/null +++ b/bats_ai/core/migrations/0034_alter_spectrogramimage_type.py @@ -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, + ), + ), + ] diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index ad1b7672..94144d94 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -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] diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py index a390558c..9755b190 100644 --- a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -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] diff --git a/bats_ai/core/models/spectrogram_image.py b/bats_ai/core/models/spectrogram_image.py index b3786a12..c757908b 100644 --- a/bats_ai/core/models/spectrogram_image.py +++ b/bats_ai/core/models/spectrogram_image.py @@ -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", ) diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index 33c70148..f5e4b9ed 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -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") @@ -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( @@ -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"]: diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index 68331d4b..7d71121a 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -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): @@ -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") @@ -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 @@ -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", ) @@ -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) @@ -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, diff --git a/bats_ai/core/utils/image_utils.py b/bats_ai/core/utils/image_utils.py new file mode 100644 index 00000000..ce2c72f4 --- /dev/null +++ b/bats_ai/core/utils/image_utils.py @@ -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 diff --git a/bats_ai/core/views/nabat/nabat_recording.py b/bats_ai/core/views/nabat/nabat_recording.py index 0e4d498b..c4c35354 100644 --- a/bats_ai/core/views/nabat/nabat_recording.py +++ b/bats_ai/core/views/nabat/nabat_recording.py @@ -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, diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 23c9cdde..ee8ce7cc 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -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 @@ -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, diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index ce77f6c0..45883267 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -2,19 +2,21 @@ 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 @@ -22,6 +24,7 @@ class SpectrogramAssetResult(TypedDict): class SpectrogramCompressedAssetResult(TypedDict): paths: list[str] masks: list[str] + waveplot_paths: NotRequired[list[str]] width: int height: int widths: list[float] @@ -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 @@ -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 diff --git a/client/src/api/api.ts b/client/src/api/api.ts index b479d2b5..e646bbc7 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -122,6 +122,7 @@ export interface UpdateFileAnnotation { export interface Spectrogram { urls: string[]; mask_urls: string[]; + waveplot_urls?: string[]; filename?: string; annotations?: SpectrogramAnnotation[]; fileAnnotations: FileAnnotation[]; @@ -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; } diff --git a/client/src/components/PulseMetadataTooltip.vue b/client/src/components/PulseMetadataTooltip.vue index f130855c..06935af6 100644 --- a/client/src/components/PulseMetadataTooltip.vue +++ b/client/src/components/PulseMetadataTooltip.vue @@ -77,13 +77,6 @@ export default defineComponent({ Knee {{ data.kneeKhz.toFixed(1) }} kHz -