diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index bafa70f5..db0b24a8 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -10,6 +10,7 @@ NABatSpectrogramAdmin, ) from .processing_task import ProcessingTaskAdmin +from .pulse_metadata import PulseMetadataAdmin from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin from .recording_tag import RecordingTagAdmin @@ -34,6 +35,7 @@ 'ExportedAnnotationFileAdmin', 'SpectrogramImageAdmin', 'VettingDetailsAdmin', + 'PulseMetadataAdmin', # NABat Models 'NABatRecordingAnnotationAdmin', 'NABatCompressedSpectrogramAdmin', diff --git a/bats_ai/core/admin/compressed_spectrogram.py b/bats_ai/core/admin/compressed_spectrogram.py index 5744c7d9..96d27923 100644 --- a/bats_ai/core/admin/compressed_spectrogram.py +++ b/bats_ai/core/admin/compressed_spectrogram.py @@ -15,6 +15,7 @@ class CompressedSpectrogramAdmin(admin.ModelAdmin): 'starts', 'stops', 'image_url_list_display', + 'mask_url_list_display', ] list_display_links = ['pk', 'recording', 'spectrogram'] list_select_related = True @@ -28,6 +29,7 @@ class CompressedSpectrogramAdmin(admin.ModelAdmin): 'starts', 'stops', 'image_url_list_display', + 'mask_url_list_display', ] @admin.display(description='Image URLs') @@ -39,3 +41,13 @@ def image_url_list_display(self, obj): return format_html_join( '\n', '
', ((url, url) for url in urls) ) + + @admin.display(description='Mask URLs') + def mask_url_list_display(self, obj): + """Render each mask URL as a clickable link in admin detail view.""" + urls = obj.mask_url_list + if not urls: + return '(No masks)' + return format_html_join( + '\n', '', ((url, url) for url in urls) + ) diff --git a/bats_ai/core/admin/pulse_metadata.py b/bats_ai/core/admin/pulse_metadata.py new file mode 100644 index 00000000..d1d07e25 --- /dev/null +++ b/bats_ai/core/admin/pulse_metadata.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from bats_ai.core.models import PulseMetadata + + +@admin.register(PulseMetadata) +class PulseMetadataAdmin(admin.ModelAdmin): + list_display = ('recording', 'index', 'bounding_box') + list_select_related = True diff --git a/bats_ai/core/management/commands/importRecordings.py b/bats_ai/core/management/commands/importRecordings.py index 1b34e942..1443ad6b 100644 --- a/bats_ai/core/management/commands/importRecordings.py +++ b/bats_ai/core/management/commands/importRecordings.py @@ -29,11 +29,13 @@ def add_arguments(self, parser): help='Username of the owner for the recordings (defaults to first superuser)', ) parser.add_argument( + '-p', '--public', action='store_true', help='Make imported recordings public', ) parser.add_argument( + '-l', '--limit', type=int, help='Limit the number of WAV files to import (useful for testing)', diff --git a/bats_ai/core/migrations/0028_alter_spectrogramimage_type_pulsemetadata.py b/bats_ai/core/migrations/0028_alter_spectrogramimage_type_pulsemetadata.py new file mode 100644 index 00000000..c605f334 --- /dev/null +++ b/bats_ai/core/migrations/0028_alter_spectrogramimage_type_pulsemetadata.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.23 on 2026-02-04 17:02 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0027_alter_annotations_end_time_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='spectrogramimage', + name='type', + field=models.CharField( + choices=[ + ('spectrogram', 'Spectrogram'), + ('compressed', 'Compressed'), + ('masks', 'Masks'), + ], + default='spectrogram', + max_length=20, + ), + ), + migrations.CreateModel( + name='PulseMetadata', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ('index', models.IntegerField()), + ('bounding_box', django.contrib.gis.db.models.fields.PolygonField(srid=4326)), + ('contours', models.JSONField(blank=True, null=True)), + ( + 'recording', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.recording' + ), + ), + ], + ), + ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 8509896a..3c254170 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -4,6 +4,7 @@ from .exported_file import ExportedAnnotationFile from .grts_cells import GRTSCells from .processing_task import ProcessingTask, ProcessingTaskType +from .pulse_metadata import PulseMetadata from .recording import Recording, RecordingTag from .recording_annotation import RecordingAnnotation from .recording_annotation_status import RecordingAnnotationStatus @@ -23,6 +24,7 @@ 'SequenceAnnotations', 'GRTSCells', 'CompressedSpectrogram', + 'PulseMetadata', 'RecordingAnnotation', 'Configuration', 'ProcessingTask', diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index 089dd4e6..4ccdbb86 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -28,12 +28,24 @@ def image_url_list(self): images = self.images.filter(type='compressed').order_by('index') return [default_storage.url(img.image_file.name) for img in images] + @property + 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 image_pil_list(self): """List of PIL images in order.""" images = self.images.filter(type='compressed').order_by('index') return [Image.open(img.image_file) for img in images] + @property + def mask_pil_list(self): + """List of PIL mask images in order.""" + images = self.images.filter(type='masks').order_by('index') + return [Image.open(img.image_file) for img in images] + @property def image_np(self): """Combined image as a single numpy array by horizontal stacking.""" diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py index d6b584bd..bf6306fe 100644 --- a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -29,12 +29,24 @@ def image_url_list(self): images = self.images.filter(type='compressed').order_by('index') return [default_storage.url(img.image_file.name) for img in images] + @property + 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 image_pil_list(self): """List of PIL images in order.""" images = self.images.filter(type='compressed').order_by('index') return [Image.open(img.image_file) for img in images] + @property + def mask_pil_list(self): + """List of PIL mask images in order.""" + images = self.images.filter(type='masks').order_by('index') + return [Image.open(img.image_file) for img in images] + @property def image_np(self): """Combined image as a single numpy array by horizontal stacking.""" diff --git a/bats_ai/core/models/pulse_metadata.py b/bats_ai/core/models/pulse_metadata.py new file mode 100644 index 00000000..b1dcc8bb --- /dev/null +++ b/bats_ai/core/models/pulse_metadata.py @@ -0,0 +1,11 @@ +from django.contrib.gis.db import models + +from .recording import Recording + + +class PulseMetadata(models.Model): + recording = models.ForeignKey(Recording, on_delete=models.CASCADE) + index = models.IntegerField(null=False, blank=False) + bounding_box = models.PolygonField(null=False, blank=False) + contours = models.JSONField(null=True, blank=True) + # TODO: Add in metadata from batbot diff --git a/bats_ai/core/models/spectrogram_image.py b/bats_ai/core/models/spectrogram_image.py index 19061b5e..08b14ce1 100644 --- a/bats_ai/core/models/spectrogram_image.py +++ b/bats_ai/core/models/spectrogram_image.py @@ -17,10 +17,11 @@ def spectrogram_image_upload_to(instance, filename): class SpectrogramImage(models.Model): - SPECTROGRAM_TYPE_CHOICES = { - 'spectrogram': 'Spectrogram', - 'compressed': 'Compressed', - } + SPECTROGRAM_TYPE_CHOICES = [ + ('spectrogram', 'Spectrogram'), + ('compressed', 'Compressed'), + ('masks', 'Masks'), + ] content_object = GenericForeignKey('content_type', 'object_id') content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index af7e7655..9afb3d61 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -4,12 +4,14 @@ import tempfile from django.contrib.contenttypes.models import ContentType +from django.contrib.gis.geos import Polygon from django.core.files import File from bats_ai.celery import app from bats_ai.core.models import ( CompressedSpectrogram, Configuration, + PulseMetadata, Recording, RecordingAnnotation, Species, @@ -88,6 +90,38 @@ def recording_compute_spectrogram(recording_id: int): }, ) + # Save mask images (from batbot metadata mask_path) + for idx, mask_path in enumerate(compressed.get('masks', [])): + with open(mask_path, 'rb') as f: + SpectrogramImage.objects.get_or_create( + content_type=ContentType.objects.get_for_model(compressed_obj), + object_id=compressed_obj.id, + index=idx, + type='masks', + defaults={ + 'image_file': File(f, name=os.path.basename(mask_path)), + }, + ) + + # Create SpectrogramContour objects for each segment + for segment in results['segments']['segments']: + PulseMetadata.objects.update_or_create( + recording=compressed_obj.recording, + index=segment['segment_index'], + defaults={ + 'contours': segment.get('contours', []), + 'bounding_box': Polygon( + ( + (segment['start_ms'], segment['freq_max']), + (segment['stop_ms'], segment['freq_max']), + (segment['stop_ms'], segment['freq_min']), + (segment['start_ms'], segment['freq_min']), + (segment['start_ms'], segment['freq_max']), + ) + ), + }, + ) + config = Configuration.objects.first() # TODO: Disabled until prediction is in batbot # https://github.com/Kitware/batbot/issues/29 diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index dab0c0bc..730a5083 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -7,12 +7,15 @@ import batbot from pydantic import BaseModel, ConfigDict, Field, field_validator +from .contour_utils import process_spectrogram_assets_for_contours + class SpectrogramMetadata(BaseModel): """Metadata about the spectrogram.""" uncompressed_path: list[str] = Field(alias='uncompressed.path') compressed_path: list[str] = Field(alias='compressed.path') + mask_path: list[str] = Field(alias='mask.path') class UncompressedSize(BaseModel): @@ -227,6 +230,7 @@ class SpectrogramAssetResult(TypedDict): class SpectrogramCompressedAssetResult(TypedDict): paths: list[str] + masks: list[str] width: int height: int widths: list[float] @@ -234,12 +238,35 @@ class SpectrogramCompressedAssetResult(TypedDict): stops: list[float] +class SpectrogramContour(TypedDict): + level: float + curve: list[list[float]] + index: int + + +class SpectrogramContourSegment(TypedDict): + segment_index: int + contour_count: int + freq_min: float + freq_max: float + contours: list[SpectrogramContour] + width_px: float + start_ms: float + stop_ms: float + + +class SpectrogramContours(TypedDict): + segments: list[SpectrogramContourSegment] + total_segments: int + + class SpectrogramAssets(TypedDict): duration: float freq_min: int freq_max: int normal: SpectrogramAssetResult compressed: SpectrogramCompressedAssetResult + segments: SpectrogramContours | None @contextmanager @@ -261,6 +288,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): # from the metadata we should have the images that are used uncompressed_paths = metadata.spectrogram.uncompressed_path compressed_paths = metadata.spectrogram.compressed_path + mask_paths = metadata.spectrogram.mask_path metadata.frequencies.min_hz metadata.frequencies.max_hz @@ -277,6 +305,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): }, 'compressed': { 'paths': compressed_paths, + 'masks': mask_paths, 'width': metadata.size.compressed.width_px, 'height': metadata.size.compressed.height_px, 'widths': compressed_metadata.widths, @@ -284,4 +313,8 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): 'stops': compressed_metadata.stops, }, } + + segments_data = process_spectrogram_assets_for_contours(result) + result['segments'] = segments_data + return result diff --git a/bats_ai/core/utils/contour_utils.py b/bats_ai/core/utils/contour_utils.py new file mode 100644 index 00000000..f3919a6e --- /dev/null +++ b/bats_ai/core/utils/contour_utils.py @@ -0,0 +1,396 @@ +import logging +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np +from scipy.ndimage import gaussian_filter1d +from skimage import measure +from skimage.filters import threshold_multiotsu + +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Level selection +# ----------------------------------------------------------------------------- + + +def auto_histogram_levels( + data: np.ndarray, + bins: int = 512, + smooth_sigma: float = 2.0, + variance_threshold: float = 400.0, + max_levels: int = 5, +) -> list[float]: + if data.size == 0: + return [] + + hist, edges = np.histogram(data, bins=bins) + counts = gaussian_filter1d(hist.astype(np.float64), sigma=smooth_sigma) + centers = (edges[:-1] + edges[1:]) / 2.0 + + mask = counts > 0 + counts = counts[mask] + centers = centers[mask] + + if counts.size == 0: + return [] + + groups = [] + current_centers = [] + current_weights = [] + + for center, weight in zip(centers, counts): + weight = max(float(weight), 1e-9) + current_centers.append(center) + current_weights.append(weight) + + values = np.array(current_centers, dtype=np.float64) + weights = np.array(current_weights, dtype=np.float64) + mean = np.average(values, weights=weights) + variance = np.average((values - mean) ** 2, weights=weights) + + if variance > variance_threshold and len(current_centers) > 1: + last_center = current_centers.pop() + last_weight = current_weights.pop() + + values = np.array(current_centers, dtype=np.float64) + weights = np.array(current_weights, dtype=np.float64) + if weights.sum() > 0: + groups.append(np.average(values, weights=weights)) + + current_centers = [last_center] + current_weights = [last_weight] + + if current_centers: + groups.append(np.average(current_centers, weights=current_weights)) + + groups = sorted(set(groups)) + if len(groups) <= 1: + return groups + + groups = groups[1:] + if max_levels and len(groups) > max_levels: + idx = np.linspace(0, len(groups) - 1, max_levels, dtype=int) + groups = [groups[i] for i in idx] + + return groups + + +def compute_auto_levels( + data: np.ndarray, + mode: str, + percentile_values, + multi_otsu_classes: int, + min_intensity: float, + hist_bins: int, + hist_sigma: float, + hist_variance_threshold: float, + hist_max_levels: int, +) -> list[float]: + valid = data[data >= min_intensity] + if valid.size == 0: + return [] + + if mode == 'multi-otsu': + try: + return threshold_multiotsu(valid, classes=multi_otsu_classes).tolist() + except Exception: + pass + + if mode == 'histogram': + return auto_histogram_levels( + valid, + bins=hist_bins, + smooth_sigma=hist_sigma, + variance_threshold=hist_variance_threshold, + max_levels=hist_max_levels, + ) + + return np.percentile(valid, sorted(percentile_values)).tolist() + + +# ----------------------------------------------------------------------------- +# Geometry +# ----------------------------------------------------------------------------- + + +def polygon_area(points: np.ndarray) -> float: + if len(points) < 3: + return 0.0 + x, y = points[:, 0], points[:, 1] + return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + + +def smooth_contour_spline(contour: np.ndarray, smoothing_factor=0.1) -> np.ndarray: + from scipy import interpolate + + if not np.array_equal(contour[0], contour[-1]): + contour = np.vstack([contour, contour[0]]) + + try: + tck, _ = interpolate.splprep( + [contour[:, 0], contour[:, 1]], + s=len(contour) * smoothing_factor, + per=True, + ) + alpha = np.linspace(0, 1, max(len(contour), 100)) + x, y = interpolate.splev(alpha, tck) + return np.column_stack([x, y]) + except Exception: + return contour + + +def filter_contours_by_segment( + contours, segment_boundaries: list[tuple[float, float]] +) -> list[list[tuple[np.ndarray, float]]]: + """Filter contours by segment boundaries based on x-coordinates. + + Args: + contours: List of (contour, level) tuples + segment_boundaries: List of (start_x, end_x) tuples for each segment + + Returns: + List of lists, where each inner list contains contours for that segment + """ + segment_contours: list[list[tuple[np.ndarray, float]]] = [[] for _ in segment_boundaries] + + for contour, level in contours: + # Get x-coordinates of all points in the contour + x_coords = contour[:, 0] + min_x = np.min(x_coords) + max_x = np.max(x_coords) + center_x = np.mean(x_coords) + + # Find which segment(s) this contour belongs to + # A contour belongs to a segment if its center or a + # significant portion is within the segment + for seg_idx, (seg_start, seg_end) in enumerate(segment_boundaries): + # Check if contour overlaps with this segment + # Consider it part of the segment if center is within or significant overlap + if (seg_start <= center_x < seg_end) or (min_x < seg_end and max_x > seg_start): + # Check if most of the contour is within this segment + points_in_segment = np.sum((x_coords >= seg_start) & (x_coords < seg_end)) + total_points = len(x_coords) + + # If at least 50% of points are in this segment, or center is in segment + if points_in_segment / total_points >= 0.5 or (seg_start <= center_x < seg_end): + segment_contours[seg_idx].append((contour, level)) + break # Assign to first matching segment to avoid duplicates + + return segment_contours + + +def contours_to_metadata( + contours, image_path: Path, segment_index: int | None = None, width: float | None = None +): + metadata = { + 'source_image': image_path.name, + 'contour_count': len(contours), + 'contours': [ + { + 'level': float(level), + 'curve': contour.round(3).tolist(), + } + for contour, level in contours + ], + } + if segment_index is not None: + metadata['segment_index'] = segment_index + if width is not None: + metadata['width_px'] = width + return metadata + + +def apply_transparency_mask(mat, threshold_percent): + t = np.clip(threshold_percent, 0, 100) / 100.0 + + if t <= 0: + return np.ones_like(mat, dtype=np.uint8) + if t >= 1: + return np.zeros_like(mat, dtype=np.uint8) + + return (mat > t).astype(np.uint8) + + +# ----------------------------------------------------------------------------- +# Core extraction +# ----------------------------------------------------------------------------- + + +def extract_contours( + image_path: Path, + *, + levels_mode: str, + percentile_values, + min_area: float, + smoothing_factor: float, + noise_threshold: float | None = None, + apply_noise_filter: bool = False, + **level_kwargs, +): + img = cv2.imread(str(image_path)) + if img is None: + raise RuntimeError(f'Could not read {image_path}') + + # Convert to grayscale first + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + if apply_noise_filter and noise_threshold is not None: + # Create mask of pixels above threshold in original image + # print out min and max of gray + gray = np.where(gray < noise_threshold, 0, gray) + blurred = cv2.GaussianBlur(gray, (15, 15), 3) + else: + blurred = cv2.GaussianBlur(gray, (15, 15), 3) + data = blurred + + levels = compute_auto_levels( + data, + mode=levels_mode, + percentile_values=percentile_values, + **level_kwargs, + ) + + contours = [] + for level in levels: + for c in measure.find_contours(data, level): + xy = c[:, ::-1] + if not np.array_equal(xy[0], xy[-1]): + xy = np.vstack([xy, xy[0]]) + + if polygon_area(xy) < min_area: + continue + + smooth = smooth_contour_spline(xy, smoothing_factor) + contours.append((smooth, level)) + + return sorted(contours, key=lambda x: x[1]), img.shape + + +def process_spectrogram_assets_for_contours( + assets: dict[str, Any], + levels_mode: str = 'percentile', + percentile_values: list[float] = (60, 70, 80, 90, 92, 94, 96, 98), + min_area: float = 30.0, + smoothing_factor: float = 0.08, + min_intensity: float = 1.0, + multi_otsu_classes: int = 4, + hist_bins: int = 512, + hist_sigma: float = 2.0, + hist_variance_threshold: float = 400.0, + hist_max_levels: int = 5, + noise_threshold: float | None = None, + apply_noise_filter: bool = False, +): + compressed_data = assets.get('compressed', {}) + compressed_data.get('paths', []) + mask_paths = compressed_data.get('masks', []) + widths = compressed_data.get('widths', []) + height = compressed_data.get('height', 0) + starts = compressed_data.get('starts', []) + global_freq_min = assets.get('freq_min', 0) + global_freq_max = assets.get('freq_max', 0) + stops = compressed_data.get('stops', []) + all_segments_data = [] + + processed_images: set[Path] = set() + for path_str in mask_paths: + img_path = Path(path_str).resolve() + if not img_path.exists(): + logger.warning('Image path does not exist: %s', img_path) + continue + + # Only process each unique image once + if img_path in processed_images: + continue + processed_images.add(img_path) + + # Extract all contours from the compressed image + contours, shape = extract_contours( + img_path, + levels_mode=levels_mode, + percentile_values=percentile_values, + min_area=min_area, + smoothing_factor=smoothing_factor, + min_intensity=min_intensity, + multi_otsu_classes=multi_otsu_classes, + hist_bins=hist_bins, + hist_sigma=hist_sigma, + hist_variance_threshold=hist_variance_threshold, + hist_max_levels=hist_max_levels, + noise_threshold=noise_threshold, + apply_noise_filter=False, + ) + segment_boundaries: list[tuple[float, float]] = [] + cumulative_x = 0.0 + for width in widths: + segment_boundaries.append((cumulative_x, cumulative_x + width)) + cumulative_x += width + + # Split contours by segment + segment_contours_list = filter_contours_by_segment(contours, segment_boundaries) + + # Build per-image JSON with segments array + segments_output: list[dict] = [] + width_to_this_seg = 0 + for seg_idx, seg_contours in enumerate(segment_contours_list): + # freq_min/freq_max: min/max y (frequency axis) over all contour points in this segment + all_y = ( + np.concatenate([c[:, 1] for c, _ in seg_contours]) if seg_contours else np.array([]) + ) + freq_min = float(np.min(all_y).round(3)) if all_y.size else None + freq_max = float(np.max(all_y).round(3)) if all_y.size else None + start_time = starts[seg_idx] + stop_time = stops[seg_idx] + width = widths[seg_idx] + time_per_pixel = (stop_time - start_time) / width + mhz_per_pixel = (global_freq_max - global_freq_min) / height + transformed_contours = [] + contour_index = 0 + for contour, level in seg_contours: + # contour is (N, 2): each row is one point [x, y] + new_curve = [ + [ + (point[0] - width_to_this_seg) * time_per_pixel + start_time, + global_freq_max - (point[1] * mhz_per_pixel), + ] + for point in contour + ] + transformed_contours.append( + { + 'level': float(level), + 'curve': new_curve, + 'index': seg_idx, + } + ) + contour_index += 1 + segment_obj: dict = { + 'segment_index': seg_idx, + 'contour_count': len(seg_contours), + 'freq_min': freq_min, + 'freq_max': freq_max, + 'contours': transformed_contours, + } + + if seg_idx < len(widths): + segment_obj['width_px'] = widths[seg_idx] + if seg_idx < len(starts): + segment_obj['start_ms'] = starts[seg_idx] + if seg_idx < len(stops): + segment_obj['stop_ms'] = stops[seg_idx] + + width_to_this_seg += widths[seg_idx] + segments_output.append(segment_obj) + # Collect for combined output + all_segments_data.extend(segments_output) + + # If we processed from spectrogram assets, also create a combined output + # organized by segments/widths + combined_output = { + 'segments': sorted(all_segments_data, key=lambda x: x.get('segment_index', 0)), + 'total_segments': len(all_segments_data), + } + + return combined_output diff --git a/bats_ai/core/views/nabat/nabat_recording.py b/bats_ai/core/views/nabat/nabat_recording.py index 8f2e7067..61384e1d 100644 --- a/bats_ai/core/views/nabat/nabat_recording.py +++ b/bats_ai/core/views/nabat/nabat_recording.py @@ -323,6 +323,7 @@ def get_spectrogram_compressed(request: HttpRequest, id: int, apiToken: str): spectro_data = { 'urls': compressed_spectrogram.image_url_list, + 'mask_urls': compressed_spectrogram.mask_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 a97dfd66..519b0355 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -1,7 +1,7 @@ from datetime import datetime import json import logging -from typing import List, Optional +from typing import Any from django.contrib.auth.models import User from django.contrib.gis.geos import Point @@ -16,6 +16,7 @@ from bats_ai.core.models import ( Annotations, CompressedSpectrogram, + PulseMetadata, Recording, RecordingAnnotation, RecordingTag, @@ -65,7 +66,7 @@ class RecordingUploadSchema(Schema): detector: str | None = None species_list: str | None = None unusual_occurrences: str | None = None - tags: Optional[List[str]] = None + tags: list[str] | None = None class RecordingAnnotationSchema(Schema): @@ -129,6 +130,22 @@ class UpdateAnnotationsSchema(Schema): id: int | None +class PulseMetadataSchema(Schema): + id: int | None + index: int + bounding_box: Any + contours: list + + @classmethod + def from_orm(cls, obj: PulseMetadata): + return cls( + id=obj.id, + index=obj.index, + contours=obj.contours if obj.contours is not None else [], + bounding_box=json.loads(obj.bounding_box.geojson), + ) + + @router.post('/') def create_recording( request: HttpRequest, @@ -459,6 +476,7 @@ def get_spectrogram_compressed(request: HttpRequest, id: int): spectro_data = { 'urls': compressed_spectrogram.image_url_list, + 'mask_urls': compressed_spectrogram.mask_url_list, 'spectroInfo': { 'spectroId': compressed_spectrogram.pk, 'width': compressed_spectrogram.spectrogram.width, @@ -542,6 +560,25 @@ def get_annotations(request: HttpRequest, id: int): return {'error': 'Recording not found'} +@router.get('/{id}/pulse_data') +def get_pulse_data(request: HttpRequest, id: int): + try: + recording = Recording.objects.get(pk=id) + if recording.owner == request.user or recording.public: + computed_pulse_annotation_qs = PulseMetadata.objects.filter( + recording=recording + ).order_by('index') + return [ + PulseMetadataSchema.from_orm(pulse) for pulse in computed_pulse_annotation_qs.all() + ] + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + + @router.get('/{id}/annotations/other_users') def get_other_user_annotations(request: HttpRequest, id: int): try: diff --git a/bats_ai/core/views/vetting_details.py b/bats_ai/core/views/vetting_details.py index 38a9d5eb..3896e17a 100644 --- a/bats_ai/core/views/vetting_details.py +++ b/bats_ai/core/views/vetting_details.py @@ -14,7 +14,6 @@ class VettingDetailsSchema(Schema): @classmethod def from_orm(cls, obj): - print(obj) return cls(id=obj.id, reference_materials=obj.reference_materials, user_id=obj.user_id) diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index 2f60a3bc..1d2d178a 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -26,6 +26,7 @@ class SpectrogramAssetResult(TypedDict): class SpectrogramCompressedAssetResult(TypedDict): paths: list[str] + masks: list[str] width: int height: int widths: list[float] @@ -190,4 +191,17 @@ def generate_nabat_compressed_spectrogram( }, ) + # Save mask images (from batbot metadata mask_path) + for idx, mask_path in enumerate(compressed_results.get('masks', [])): + with open(mask_path, 'rb') as f: + SpectrogramImage.objects.get_or_create( + content_type=ContentType.objects.get_for_model(compressed_obj), + object_id=compressed_obj.id, + index=idx, + type='masks', + defaults={ + 'image_file': File(f, name=os.path.basename(mask_path)), + }, + ) + return compressed_obj diff --git a/client/src/App.vue b/client/src/App.vue index 823ef327..0b35626c 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -12,6 +12,7 @@ export default defineComponent({ const oauthClient = inject