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 -
- - Slope Hi Avg: {{ data.slopeHiAvg.toFixed(2) }} kHz/ms - -
Fc {{ data.fcKhz.toFixed(1) }} kHz
-
+
+ + Slope Hi: {{ data.slopeHi.toFixed(2) }} kHz/ms + +
+
+ + Slope Total:{{ data.slopeTotal.toFixed(2) }} kHz/ms + +
+
- Slope Total Avg:{{ data.slopeTotalAvg.toFixed(2) }} kHz/ms + Slope Low: {{ data.slopeLow.toFixed(2) }} kHz/ms
diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index 72792955..b7b17234 100644 --- a/client/src/components/SpectrogramViewer.vue +++ b/client/src/components/SpectrogramViewer.vue @@ -23,6 +23,7 @@ export default defineComponent({ props: { images: { type: Array as PropType, default: () => [] }, maskImages: { type: Array as PropType, default: () => [] }, + waveplotImages: { type: Array as PropType, default: () => [] }, spectroInfo: { type: Object as PropType, required: true }, recordingId: { type: String as PropType, required: true }, compressed: { type: Boolean, required: true } @@ -43,6 +44,7 @@ export default defineComponent({ contoursEnabled, imageOpacity, viewMaskOverlay, + viewWaveplot, maskOverlayOpacity, } = useState(); @@ -55,6 +57,13 @@ export default defineComponent({ const setCursor = (newCursor: string) => { cursor.value = newCursor; }; + const baseDimensions = computed(() => getImageDimensions(props.images, props.spectroInfo)); + const waveplotDisplayHeight = computed(() => Math.floor(baseDimensions.value.height / 5)); + const showWaveplot = computed(() => (viewWaveplot.value && props.waveplotImages.length && waveplotDisplayHeight.value > 0)); + const totalDisplayHeight = computed(() => + scaledHeight.value + (showWaveplot.value ? waveplotDisplayHeight.value : 0) + ); + function updateScaledDimensions() { const { width, height } = getImageDimensions(props.images, props.spectroInfo); scaledWidth.value = width * scaledVals.value.x; @@ -125,10 +134,60 @@ export default defineComponent({ const effectiveImageOpacity = computed(() => (contoursEnabled.value ? imageOpacity.value : 1)); + function resetViewerBounds(resetCam = false) { + const viewer = geoJS.getGeoViewer().value; + if (!viewer) return; + const totalHeight = totalDisplayHeight.value; + const prevCenter = viewer.center?.(); + const prevZoom = viewer.zoom?.(); + + // Always reset maxBounds/clamping to current content size, but avoid the default "fit everything" + // camera reset (we want a left-anchored view that fits vertical height). + geoJS.resetMapDimensions(scaledWidth.value, totalHeight, 0.3, false); + + if (!resetCam) { + // Preserve current view when just resizing content extents. + if (prevZoom != null) viewer.zoom(prevZoom); + if (prevCenter != null) viewer.center(prevCenter); + return; + } + + const viewport = viewer.camera?.()?.viewport; + const viewportW = viewport?.width ?? 0; + const viewportH = viewport?.height ?? 0; + const aspect = viewportW > 0 && viewportH > 0 ? viewportW / viewportH : 1; + + // Show the left edge (with padding) and fit the full vertical height. + const pad = Math.max(10, scaledWidth.value * 0.01); + const desiredWidth = Math.max(200, totalHeight * aspect); + const left = -pad; + const right = Math.min(scaledWidth.value + pad, left + desiredWidth); + const viewBounds = { left, top: 0, right, bottom: totalHeight }; + + const zoomAndCenter = viewer.zoomAndCenterFromBounds(viewBounds, 0); + viewer.zoom(zoomAndCenter.zoom); + viewer.center(zoomAndCenter.center); + } + + function drawWaveplotIfEnabled() { + if (showWaveplot.value) { + geoJS.drawWaveplotImages( + props.waveplotImages, + scaledWidth.value, + waveplotDisplayHeight.value, + scaledHeight.value, + 1 + ); + } else { + geoJS.clearWaveplotQuadFeatures(true); + } + } + function initializeViewerAndImages() { updateScaledDimensions(); + const totalHeight = totalDisplayHeight.value; if (containerRef.value && !geoJS.getGeoViewer().value) { - geoJS.initializeViewer(containerRef.value, scaledWidth.value, scaledHeight.value, false, props.images.length); + geoJS.initializeViewer(containerRef.value, scaledWidth.value, totalHeight, false, props.images.length); geoJS.getGeoViewer().value.geoOn(geo.event.mousemove, mouseMoveEvent); } if (props.images.length) { @@ -137,18 +196,21 @@ export default defineComponent({ if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + resetViewerBounds(true); initialized.value = true; emit("geoViewerRef", geoJS.getGeoViewer()); if (props.compressed) { scaledVals.value = { x: configuration.value.spectrogram_x_stretch, y: 1 }; updateScaledDimensions(); + resetViewerBounds(true); if (props.images.length) { geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity.value); } if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); } } @@ -163,19 +225,14 @@ export default defineComponent({ watch(() => props.spectroInfo, () => { updateScaledDimensions(); - geoJS.resetMapDimensions(scaledWidth.value, scaledHeight.value); - geoJS.getGeoViewer().value.bounds({ - left: 0, - top: 0, - bottom: scaledHeight.value, - right: scaledWidth.value, - }); + resetViewerBounds(true); if (props.images.length) { geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, true, effectiveImageOpacity.value); } if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); }); const updateAnnotation = async ( @@ -231,22 +288,26 @@ export default defineComponent({ scaledVals.value.x += event.deltaY > 0 ? -incrementX : incrementX; if (scaledVals.value.x < 1) scaledVals.value.x = 1; updateScaledDimensions(); + resetViewerBounds(false); if (props.images.length) { geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity.value); } if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); } else if (event.shiftKey) { scaledVals.value.y += event.deltaY > 0 ? -incrementY : incrementY; if (scaledVals.value.y < 1) scaledVals.value.y = 1; updateScaledDimensions(); + resetViewerBounds(false); if (props.images.length) { geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity.value); } if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); } }; @@ -266,6 +327,11 @@ export default defineComponent({ } }); + watch([showWaveplot], () => { + resetViewerBounds(false); + drawWaveplotIfEnabled(); + }); + function onPulseMetadataTooltip(data: PulseMetadataTooltipData | null) { pulseMetadataTooltipData.value = data; } @@ -285,7 +351,7 @@ export default defineComponent({ wheelEvent, scaledWidth, scaledHeight, - backgroundColor + backgroundColor, }; }, }); diff --git a/client/src/components/ThumbnailViewer.vue b/client/src/components/ThumbnailViewer.vue index 23ed86cc..af1946a1 100644 --- a/client/src/components/ThumbnailViewer.vue +++ b/client/src/components/ThumbnailViewer.vue @@ -1,6 +1,6 @@