From 9c7a43eeed2ee6478248a25375c9c219a794010c Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 16 Mar 2026 14:29:32 -0400 Subject: [PATCH 01/12] swap to using new box slope values --- .../src/components/PulseMetadataTooltip.vue | 27 ++++++++++++------- .../geoJS/layers/pulseMetadataLayer.ts | 12 ++++----- pyproject.toml | 1 + scripts/batbot/batbot_spectrogram.py | 2 +- 4 files changed, 25 insertions(+), 17 deletions(-) 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/geoJS/layers/pulseMetadataLayer.ts b/client/src/components/geoJS/layers/pulseMetadataLayer.ts index 1b5238c0..93ccc9b7 100644 --- a/client/src/components/geoJS/layers/pulseMetadataLayer.ts +++ b/client/src/components/geoJS/layers/pulseMetadataLayer.ts @@ -41,10 +41,9 @@ export interface PulseMetadataTooltipData { charFreqColor: string | null; heelKhz: number | null; kneeKhz: number | null; - /** Slope at Hi Avg. */ - slopeHiAvg: number | null; - /** Slope at Total Avg. */ - slopeTotalAvg: number | null; + slopeLow: number | null + slopeHi: number | null; + slopeTotal: number | null; bbox: { top: number; left: number; width: number; height: number }; } @@ -291,8 +290,9 @@ export default class PulseMetadataLayer extends BaseTextLayer { heelKhz: pulse.heel ? pulse.heel[1] / 1000 : null, kneeKhz: pulse.knee ? pulse.knee[1] / 1000 : null, charFreqColor: pulse.char_freq ? charFreqColor : null, - slopeHiAvg: slopes?.slope_hi_avg_khz_per_ms ?? null, - slopeTotalAvg: slopes?.slope_avg_khz_per_ms ?? null, + slopeHi: slopes?.slope_hi_box_khz_per_ms ?? null, + slopeLow: slopes?.slope_lo_box_khz_per_ms ?? null, + slopeTotal: slopes?.slope_avg_khz_per_ms ?? null, bbox: { top: 0, left, width, height }, }; } diff --git a/pyproject.toml b/pyproject.toml index fed3072b..adbc97bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ explicit = true [tool.uv.sources] gdal = { index = "large_image_wheels" } +batbot = { git = "https://github.com/Kitware/batbot", branch= "tas/update-metadata" } [tool.ruff] line-length = 100 diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py index 6d51b6bc..c455c212 100644 --- a/scripts/batbot/batbot_spectrogram.py +++ b/scripts/batbot/batbot_spectrogram.py @@ -7,7 +7,7 @@ # ] # # [tool.uv.sources] -# batbot = { git = "https://github.com/Kitware/batbot" } +# batbot = { git = "https://github.com/Kitware/batbot", branch= "tas/update-metadata" } # /// from __future__ import annotations From ac806c43230c5b75f14667976942a7175ae5e435 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 16 Mar 2026 14:29:47 -0400 Subject: [PATCH 02/12] specific branch lockfile --- uv.lock | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/uv.lock b/uv.lock index 914a6c25..5a320b0b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -185,7 +185,7 @@ wheels = [ [[package]] name = "batbot" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/Kitware/batbot?branch=tas%2Fupdate-metadata#5e04bc7b8d25b71e7a27ba54e96171d41969d0bd" } dependencies = [ { name = "click" }, { name = "cryptography" }, @@ -204,10 +204,6 @@ dependencies = [ { name = "sphinx-click" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/41/f1702fa58ab8207e6b3f6059ee5e42e868c5b861059db1abafdc7786f390/batbot-0.1.2.tar.gz", hash = "sha256:92476da8ff3e55abc03c06ee9c2f72100b6efdbbf45e60c62612a71aa2b8c0c7", size = 3903360, upload-time = "2026-02-10T19:49:23.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/a0/216db39b5c6cfb4a00bb244419951830454b723c1d097038526f7dd5f840/batbot-0.1.2-py3-none-any.whl", hash = "sha256:56bc086784deb60af3f9649d0798ee7abb776116a5f24cb8bad1e643c9aa9c85", size = 35792, upload-time = "2026-02-10T19:49:22.017Z" }, -] [[package]] name = "bats-ai" @@ -289,7 +285,7 @@ type = [ [package.metadata] requires-dist = [ - { name = "batbot", marker = "extra == 'tasks'", specifier = "==0.1.2" }, + { name = "batbot", marker = "extra == 'tasks'", git = "https://github.com/Kitware/batbot?branch=tas%2Fupdate-metadata" }, { name = "celery", specifier = "==5.6.2" }, { name = "django", extras = ["argon2"], specifier = "==6.0.3" }, { name = "django-allauth", specifier = "==65.15.0" }, From 8a1fd257b8092fa657a2833f509d0de036d46041 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 16 Mar 2026 15:19:40 -0400 Subject: [PATCH 03/12] store waveplot images --- .../0034_alter_spectrogramimage_type.py | 28 +++++++++++++++++ bats_ai/core/models/spectrogram_image.py | 4 ++- bats_ai/core/tasks/tasks.py | 27 +++++++++++++++- bats_ai/core/utils/batbot_metadata.py | 6 ++++ bats_ai/core/utils/image_utils.py | 28 +++++++++++++++++ bats_ai/utils/spectrogram_utils.py | 31 ++++++++++++++++++- pyproject.toml | 3 +- scripts/batbot/batbot_spectrogram.py | 12 +++++-- uv.lock | 6 ++-- 9 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 bats_ai/core/migrations/0034_alter_spectrogramimage_type.py create mode 100644 bats_ai/core/utils/image_utils.py 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/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..ebd4623d 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 (from batbot metadata compressed.waveplot.path) + 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..42c8877c 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): @@ -371,6 +373,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 +384,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..a15d0c66 --- /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 + +from PIL import Image + + +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. + """ + 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)]) + buf = BytesIO() + out.save(buf, format="PNG") + buf.seek(0) + return buf 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/pyproject.toml b/pyproject.toml index adbc97bc..4e20a781 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ tasks = [ # Heavy duty dependencies used for computationally # intensive tasks, such as spectrogram generation "batbot==0.1.2", + "Pillow>=11.0.0", "numpy==2.4.3", "scipy==1.17.1", "scikit-image==0.26.0", @@ -112,7 +113,7 @@ explicit = true [tool.uv.sources] gdal = { index = "large_image_wheels" } -batbot = { git = "https://github.com/Kitware/batbot", branch= "tas/update-metadata" } +batbot = { git = "https://github.com/Kitware/batbot", branch= "restructure-waveplot-metadata" } [tool.ruff] line-length = 100 diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py index c455c212..896ed81b 100644 --- a/scripts/batbot/batbot_spectrogram.py +++ b/scripts/batbot/batbot_spectrogram.py @@ -7,7 +7,7 @@ # ] # # [tool.uv.sources] -# batbot = { git = "https://github.com/Kitware/batbot", branch= "tas/update-metadata" } +# batbot = { git = "https://github.com/Kitware/batbot", branch= "restructure-waveplot-metadata" } # /// from __future__ import annotations @@ -30,6 +30,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): @@ -274,7 +276,9 @@ def working_directory(path): def generate_spectrogram_assets( recording_path: str, *, output_folder: str, debug: bool = False ) -> SpectrogramAssets: - batbot.pipeline(recording_path, output_folder=output_folder, debug=debug) + batbot.pipeline( + recording_path, output_folder=output_folder, debug=debug, plot_uncompressed_amplitude=True + ) # There should be a .metadata.json file in the output_base directory by replacing extentions metadata_file = Path(recording_path).with_suffix(".metadata.json").name metadata_file = Path(output_folder) / metadata_file @@ -290,6 +294,8 @@ def _normalize_paths(paths: list[str]) -> list[str]: uncompressed_paths = _normalize_paths(metadata.spectrogram.uncompressed_path) compressed_paths = _normalize_paths(metadata.spectrogram.compressed_path) mask_paths = _normalize_paths(metadata.spectrogram.mask_path) + waveplot_paths = _normalize_paths(metadata.spectrogram.waveplot_path) + compressed_waveplot_paths = _normalize_paths(metadata.spectrogram.waveplot_compressed_path) compressed_metadata = convert_to_compressed_spectrogram_data(metadata) @@ -319,6 +325,7 @@ def _normalize_paths(paths: list[str]) -> list[str]: "noise_filter_threshold": noise_threshold_percent, "normal": { "paths": uncompressed_paths, + "waveplots": waveplot_paths, "width": metadata.size.uncompressed.width_px, "height": metadata.size.uncompressed.height_px, "widths": uncompressed_widths, @@ -326,6 +333,7 @@ def _normalize_paths(paths: list[str]) -> list[str]: "compressed": { "paths": compressed_paths, "masks": mask_paths, + "waveplots": compressed_waveplot_paths, "width": metadata.size.compressed.width_px, "height": metadata.size.compressed.height_px, "widths": compressed_metadata.widths, diff --git a/uv.lock b/uv.lock index 5a320b0b..14a9cc66 100644 --- a/uv.lock +++ b/uv.lock @@ -185,7 +185,7 @@ wheels = [ [[package]] name = "batbot" version = "0.1.2" -source = { git = "https://github.com/Kitware/batbot?branch=tas%2Fupdate-metadata#5e04bc7b8d25b71e7a27ba54e96171d41969d0bd" } +source = { git = "https://github.com/Kitware/batbot?branch=restructure-waveplot-metadata#a450bf8f922acda17a2ef6f61abe2c2c102e7b9b" } dependencies = [ { name = "click" }, { name = "cryptography" }, @@ -255,6 +255,7 @@ tasks = [ { name = "batbot" }, { name = "numpy" }, { name = "opencv-python-headless" }, + { name = "pillow" }, { name = "scikit-image" }, { name = "scipy" }, ] @@ -285,7 +286,7 @@ type = [ [package.metadata] requires-dist = [ - { name = "batbot", marker = "extra == 'tasks'", git = "https://github.com/Kitware/batbot?branch=tas%2Fupdate-metadata" }, + { name = "batbot", marker = "extra == 'tasks'", git = "https://github.com/Kitware/batbot?branch=restructure-waveplot-metadata" }, { name = "celery", specifier = "==5.6.2" }, { name = "django", extras = ["argon2"], specifier = "==6.0.3" }, { name = "django-allauth", specifier = "==65.15.0" }, @@ -315,6 +316,7 @@ requires-dist = [ { name = "ipython", marker = "extra == 'development'", specifier = "==9.11.0" }, { name = "numpy", marker = "extra == 'tasks'", specifier = "==2.4.3" }, { name = "opencv-python-headless", marker = "extra == 'tasks'", specifier = "==4.13.0.92" }, + { name = "pillow", marker = "extra == 'tasks'", specifier = ">=11.0.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.3" }, { name = "pydantic", specifier = "==2.12.5" }, { name = "requests", specifier = "==2.32.5" }, From 237d5a90355edef1373cef3d512600c5819aed71 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 16 Mar 2026 15:23:44 -0400 Subject: [PATCH 04/12] add waveplot urls to the return data for a spectrogram --- bats_ai/core/models/compressed_spectrogram.py | 6 ++++++ bats_ai/core/models/nabat/nabat_compressed_spectrogram.py | 6 ++++++ bats_ai/core/views/nabat/nabat_recording.py | 1 + bats_ai/core/views/recording.py | 1 + client/src/api/api.ts | 1 + 5 files changed, 15 insertions(+) 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/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..fdc94221 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -810,6 +810,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/client/src/api/api.ts b/client/src/api/api.ts index b479d2b5..b92079af 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[]; From 74a4cfcc6fa02cbac53e2aef01d99d3f3e1a2a05 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 16 Mar 2026 15:43:53 -0400 Subject: [PATCH 05/12] remove older mid box value --- bats_ai/core/utils/batbot_metadata.py | 3 --- bats_ai/core/views/recording.py | 1 - scripts/batbot/batbot_spectrogram.py | 1 - 3 files changed, 5 deletions(-) diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index 42c8877c..7d71121a 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -106,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") @@ -281,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 @@ -296,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", ) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index fdc94221..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 diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py index 896ed81b..555b9d4b 100644 --- a/scripts/batbot/batbot_spectrogram.py +++ b/scripts/batbot/batbot_spectrogram.py @@ -109,7 +109,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") From b8949448c898d47f4b534964963f096c8809b4c4 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 16 Mar 2026 15:50:42 -0400 Subject: [PATCH 06/12] add support for waveplot images --- client/src/api/api.ts | 1 - client/src/use/useState.ts | 3 +++ client/src/views/Spectrogram.vue | 29 +++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index b92079af..e646bbc7 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -666,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/use/useState.ts b/client/src/use/useState.ts index d62e2fb1..1b03814e 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -95,6 +95,8 @@ const contoursLoading = ref(false); // Default mask overlay to visible at 50% opacity. const viewMaskOverlay = ref(true); const maskOverlayOpacity = ref(0.50); +// Waveplot below compressed spectrogram; on by default when available. +const viewWaveplot = ref(true); const setContoursEnabled = (value: boolean) => { contoursEnabled.value = value; }; @@ -283,6 +285,7 @@ export default function useState() { loadReviewerMaterials, viewMaskOverlay, maskOverlayOpacity, + viewWaveplot, filterTags, sharedFilterTags, saveFilterTags, diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index 565a2adf..29d8a934 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -73,6 +73,7 @@ export default defineComponent({ selectedType, scaledVals, viewCompressedOverlay, + viewWaveplot, sideTab, configuration, measuring, @@ -101,6 +102,7 @@ export default defineComponent({ const router = useRouter(); const images: Ref = ref([]); const maskImages: Ref = ref([]); + const waveplotImages: Ref = ref([]); const spectroInfo: Ref = ref(); const speciesList: Ref = ref([]); const loadedImage = ref(false); @@ -195,6 +197,14 @@ export default defineComponent({ maskImages.value.push(image); }); } + waveplotImages.value = []; + if (spectrogramData.value.waveplot_urls?.length) { + spectrogramData.value.waveplot_urls.forEach((url) => { + const image = new Image(); + image.src = url; + waveplotImages.value.push(image); + }); + } spectroInfo.value = response.data["spectroInfo"]; if (spectrogramData.value['compressed'] && spectroInfo.value) { spectroInfo.value.start_times = spectrogramData.value.compressed.start_times; @@ -366,6 +376,7 @@ export default defineComponent({ loading, images, maskImages, + waveplotImages, spectroInfo, annotations, selectedId, @@ -385,6 +396,7 @@ export default defineComponent({ freqRef, toggleCompressedOverlay, viewCompressedOverlay, + viewWaveplot, sideTab, colorSchemes, colorScheme, @@ -661,6 +673,23 @@ export default defineComponent({ :compressed="compressed" />
+ + + Toggle wave plot below spectrogram +
Date: Tue, 17 Mar 2026 09:36:23 -0400 Subject: [PATCH 07/12] rendering amplitude waveform --- bats_ai/core/tasks/tasks.py | 2 +- bats_ai/core/utils/image_utils.py | 2 +- client/src/components/SpectrogramViewer.vue | 38 +++++++- client/src/components/ThumbnailViewer.vue | 58 +++++++++++-- client/src/components/geoJS/geoJSUtils.ts | 96 +++++++++++++++++++++ client/src/views/Spectrogram.vue | 2 + uv.lock | 2 +- 7 files changed, 189 insertions(+), 11 deletions(-) diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index ebd4623d..f5e4b9ed 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -134,7 +134,7 @@ def recording_compute_spectrogram(self, recording_id: int): }, ) - # Save waveform images that are created during compression (from batbot metadata compressed.waveplot.path) + # 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] diff --git a/bats_ai/core/utils/image_utils.py b/bats_ai/core/utils/image_utils.py index a15d0c66..04787762 100644 --- a/bats_ai/core/utils/image_utils.py +++ b/bats_ai/core/utils/image_utils.py @@ -21,7 +21,7 @@ def waveplot_to_grayscale_transparent(source_path: str) -> BytesIO: 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)]) + 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) diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index 72792955..1027cd18 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(() => Boolean(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,25 @@ export default defineComponent({ const effectiveImageOpacity = computed(() => (contoursEnabled.value ? imageOpacity.value : 1)); + 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,6 +161,7 @@ export default defineComponent({ if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); initialized.value = true; emit("geoViewerRef", geoJS.getGeoViewer()); @@ -149,6 +174,7 @@ export default defineComponent({ if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); } } @@ -176,6 +202,7 @@ export default defineComponent({ if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); }); const updateAnnotation = async ( @@ -237,6 +264,7 @@ export default defineComponent({ 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; @@ -247,6 +275,7 @@ export default defineComponent({ if (viewMaskOverlay.value && props.maskImages.length) { geoJS.drawMaskImages(props.maskImages, scaledWidth.value, scaledHeight.value, maskOverlayOpacity.value); } + drawWaveplotIfEnabled(); } }; @@ -266,6 +295,11 @@ export default defineComponent({ } }); + watch([showWaveplot], () => { + const totalHeight = totalDisplayHeight.value; + drawWaveplotIfEnabled(); + }); + function onPulseMetadataTooltip(data: PulseMetadataTooltipData | null) { pulseMetadataTooltipData.value = data; } @@ -285,7 +319,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..7b21da8b 100644 --- a/client/src/components/ThumbnailViewer.vue +++ b/client/src/components/ThumbnailViewer.vue @@ -1,6 +1,6 @@