From e80479af46afc370fda3320147ee66e982c1f134 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Tue, 18 Nov 2025 11:36:43 -0500 Subject: [PATCH 1/9] Add model for vector version of spectrogram --- bats_ai/core/models/compressed_spectrogram.py | 7 +++ bats_ai/core/models/spectrogram.py | 7 +++ bats_ai/core/models/spectrogram_vector.py | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 bats_ai/core/models/spectrogram_vector.py diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index f4fa734a..d5bdbd32 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -9,6 +9,7 @@ from .recording import Recording from .spectrogram import Spectrogram from .spectrogram_image import SpectrogramImage +from .spectrogram_vector import SpectrogramSvg # TimeStampedModel also provides "created" and "modified" fields @@ -17,6 +18,7 @@ class CompressedSpectrogram(TimeStampedModel, models.Model): spectrogram = models.ForeignKey(Spectrogram, on_delete=models.CASCADE) length = models.IntegerField() images = GenericRelation(SpectrogramImage) + vector_images = GenericRelation(SpectrogramSvg) starts = ArrayField(ArrayField(models.IntegerField())) stops = ArrayField(ArrayField(models.IntegerField())) widths = ArrayField(ArrayField(models.IntegerField())) @@ -28,6 +30,11 @@ 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 vector_image_url_list(self): + images = self.vector_images.filter(type='compressed').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.""" diff --git a/bats_ai/core/models/spectrogram.py b/bats_ai/core/models/spectrogram.py index 1d200878..574eb8f4 100644 --- a/bats_ai/core/models/spectrogram.py +++ b/bats_ai/core/models/spectrogram.py @@ -7,11 +7,13 @@ from .recording import Recording from .spectrogram_image import SpectrogramImage +from .spectrogram_vector import SpectrogramSvg class Spectrogram(TimeStampedModel, models.Model): recording = models.ForeignKey(Recording, on_delete=models.CASCADE) images = GenericRelation(SpectrogramImage) + vector_images = GenericRelation(SpectrogramSvg) width = models.IntegerField() # pixels height = models.IntegerField() # pixels duration = models.IntegerField() # milliseconds @@ -24,6 +26,11 @@ def image_url_list(self): images = self.images.filter(type='spectrogram').order_by('index') return [default_storage.url(img.image_file.name) for img in images] + @property + def vector_url_list(self): + images = self.vector_images.filter(type='spectrogram').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.""" diff --git a/bats_ai/core/models/spectrogram_vector.py b/bats_ai/core/models/spectrogram_vector.py new file mode 100644 index 00000000..f4f47a1e --- /dev/null +++ b/bats_ai/core/models/spectrogram_vector.py @@ -0,0 +1,43 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.dispatch import receiver + + +def spectrogram_svg_upload_to(instance, filename): + related = instance.content_object + + recording = getattr(related, 'recording', None) or getattr(related, 'nabat_recording', None) + recording_id = getattr(recording, 'id', None) + + if not recording_id: + raise ValueError('Related content must have a recording or nabat_recording.') + + return f'recording_{recording_id}/{instance.type}/svg_{instance.index}_{filename}' + + +class SpectrogramSvg(models.Model): + SPECTROGRAM_TYPE_CHOICES = [ + ('spectrogram', 'Spectrogram'), + ('compressed', 'Compressed'), + ] + 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, + choices=SPECTROGRAM_TYPE_CHOICES, + default='spectrogram', + ) + index = models.PositiveIntegerField() + image_file = models.FileField(upload_to=spectrogram_svg_upload_to) + + class Meta: + ordering = ['index'] + + +@receiver(models.signals.pre_delete, sender=SpectrogramSvg) +def delete_content(sender, instance, **kwargs): + if instance.image_file: + instance.image_file.delete(save=False) From 54bec4624bc9aae44c33f4de76725927ea802ee3 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Tue, 18 Nov 2025 15:55:53 -0500 Subject: [PATCH 2/9] Add contour generation --- bats_ai/core/models/__init__.py | 2 + bats_ai/tasks/tasks.py | 7 + bats_ai/utils/contour_utils.py | 353 +++++++++++++++++++++++++++++ bats_ai/utils/spectrogram_utils.py | 27 ++- pyproject.toml | 2 + uv.lock | 142 ++++++++++++ 6 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 bats_ai/utils/contour_utils.py diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index ad11fab9..600441d4 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -12,6 +12,7 @@ from .species import Species from .spectrogram import Spectrogram from .spectrogram_image import SpectrogramImage +from .spectrogram_vector import SpectrogramSvg __all__ = [ 'Annotations', @@ -30,4 +31,5 @@ 'ProcessingTaskType', 'ExportedAnnotationFile', 'SpectrogramImage', + 'SpectrogramSvg', ] diff --git a/bats_ai/tasks/tasks.py b/bats_ai/tasks/tasks.py index 33a2783f..48703427 100644 --- a/bats_ai/tasks/tasks.py +++ b/bats_ai/tasks/tasks.py @@ -15,6 +15,7 @@ Species, Spectrogram, SpectrogramImage, + SpectrogramSvg, ) from bats_ai.utils.spectrogram_utils import generate_spectrogram_assets, predict_from_compressed @@ -59,6 +60,12 @@ def recording_compute_spectrogram(recording_id: int): }, ) + for idx, svg_path in enumerate(results['normal']['vectors']): + with open(svg_path, 'rb') as f: + # TODO + SpectrogramSvg.objects.get_or_create() + + # Create or get CompressedSpectrogram compressed = results['compressed'] compressed_obj, _ = CompressedSpectrogram.objects.get_or_create( diff --git a/bats_ai/utils/contour_utils.py b/bats_ai/utils/contour_utils.py new file mode 100644 index 00000000..06cef1db --- /dev/null +++ b/bats_ai/utils/contour_utils.py @@ -0,0 +1,353 @@ +import logging + +import cv2 +import numpy as np +from scipy import ndimage +from scipy.ndimage import gaussian_filter1d +from skimage import measure +from skimage.filters import threshold_multiotsu +import svgwrite + +logger = logging.getLogger(__name__) + + +# This function computes the contour levels based on the selected mode. +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]: + """Select intensity levels by grouping histogram bins until variance exceeds a threshold.""" + 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: + grouped_mean = np.average(values, weights=weights) + groups.append(grouped_mean) + + current_centers = [last_center] + current_weights = [last_weight] + + if current_centers: + values = np.array(current_centers, dtype=np.float64) + weights = np.array(current_weights, dtype=np.float64) + grouped_mean = np.average(values, weights=weights) + groups.append(grouped_mean) + + groups = sorted(set(groups)) + + if len(groups) <= 1: + return groups + + groups = groups[1:] + + if max_levels is not None and len(groups) > max_levels: + indices = np.linspace(0, len(groups) - 1, max_levels, dtype=int) + groups = [groups[i] for i in indices] + + def subdivide_high_end(levels: list[float]) -> list[float]: + if len(levels) < 2: + return levels + gaps = np.diff(levels) + largest_gap_idx = int(np.argmax(gaps)) + remaining_slots = ( + max(0, max_levels - len(levels)) if max_levels is not None else len(levels) + ) + subdivisions = min(remaining_slots, 2) if remaining_slots > 0 else 0 + subdivided = [] + if subdivisions > 0: + if largest_gap_idx == len(levels) - 1: + low = levels[-2] + high = levels[-1] + stride = (high - low) / (subdivisions + 1) + subdivided = [low + stride * (i + 1) for i in range(subdivisions)] + levels = levels[:-1] + subdivided + [levels[-1]] + return sorted(levels) + + return subdivide_high_end(groups) + + +def compute_auto_levels( + data: np.ndarray, + mode: str, + percentile_values, + multi_otsu_classes: int, + min_intensity: float, + hist_bins: int = 512, + hist_sigma: float = 2.0, + hist_variance_threshold: float = 400.0, + hist_max_levels: int = 5, +) -> list[float]: + """Compute contour levels based on selected mode.""" + percentile_values = list(percentile_values) + percentile_values.sort() + + valid = data[data >= min_intensity] + if valid.size == 0: + return [] + + if mode == 'multi-otsu': + try: + thresholds = threshold_multiotsu(valid, classes=multi_otsu_classes) + return thresholds.tolist() + except Exception: + # Fallback to simple percentiles if multi-otsu fails + if len(percentile_values) == 0: + return [] + return np.percentile(valid, percentile_values).tolist() + elif mode == 'histogram': + return auto_histogram_levels( + valid, + bins=hist_bins, + smooth_sigma=hist_sigma, + variance_threshold=hist_variance_threshold, + max_levels=hist_max_levels, + ) + else: # percentile mode + if len(percentile_values) == 0: + return [] + return np.percentile(valid, percentile_values).tolist() + + +# This function computes the area of a polygon. +def polygon_area(points: np.ndarray) -> float: + """Return absolute area of a closed polygon given as Nx2 array.""" + if len(points) < 3: + return 0.0 + x = points[:, 0] + y = points[:, 1] + return 0.5 * np.abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + + +# This function smooths a contour using spline interpolation. +def smooth_contour_spline(contour, smoothing_factor=0.1): + """Smooth contour using spline interpolation""" + # Reshape contour + contour = contour.reshape(-1, 2) + + # Close the contour by adding first point at end + if not np.array_equal(contour[0], contour[-1]): + contour = np.vstack([contour, contour[0]]) + + # Calculate cumulative distance along contour + distances = np.cumsum(np.sqrt(np.sum(np.diff(contour, axis=0) ** 2, axis=1))) + distances = np.insert(distances, 0, 0) + + # Interpolate using splines + from scipy import interpolate + + # Create periodic spline + num_points = max(len(contour), 100) + alpha = np.linspace(0, 1, num_points) + + # Fit spline + try: + tck, u = interpolate.splprep( + [contour[:, 0], contour[:, 1]], s=len(contour) * smoothing_factor, per=True + ) + smooth_points, _ = interpolate.splev(alpha, tck) + smooth_contour = np.column_stack(smooth_points) + except Exception as e: + # Fallback to simple smoothing if spline fails + logger.info(f'Spline fitting failed {e}. Falling back to simple smoothing.') + smooth_contour = contour + + return smooth_contour + + +# This function saves the contours to an SVG file. +def save_contours_to_svg( + contours_with_levels, + output_path, + image_shape, + reference_image=None, + fill_opacity=0.6, + stroke_opacity=0.9, + stroke_width=1.0, + draw_stroke=True, + sample_shrink_px=3, + sample_radius=5, +): + """Save contours to SVG with filled shapes (optionally matching image colors).""" + height, width = image_shape[:2] + dwg = svgwrite.Drawing(output_path, size=(width, height)) + + # Default palette if no image supplied + colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#F7B267', '#CDB4DB'] + + if reference_image is not None and reference_image.shape[:2] != (height, width): + raise ValueError("reference_image shape does not match image_shape") + + def color_from_image(points, fallback_color): + if reference_image is None: + return fallback_color + polygon = np.round(points).astype(np.int32) + polygon[:, 0] = np.clip(polygon[:, 0], 0, width - 1) + polygon[:, 1] = np.clip(polygon[:, 1], 0, height - 1) + mask = np.zeros((height, width), dtype=np.uint8) + cv2.fillPoly(mask, [polygon], (255,)) + + eroded = mask.copy() + if sample_shrink_px > 0: + kernel_size = sample_shrink_px * 2 + 1 + kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8) + eroded = cv2.erode(mask, kernel, iterations=1) + if not np.count_nonzero(eroded): + eroded = mask + + dist = cv2.distanceTransform(eroded, cv2.DIST_L2, 5) + _, max_val, _, max_loc = cv2.minMaxLoc(dist) + + if max_val <= 0: + region = reference_image[mask == 255] + if region.size == 0: + return fallback_color + mean_bgr = region.mean(axis=0) + else: + cx, cy = max_loc[0], max_loc[1] + x0 = max(cx - sample_radius, 0) + x1 = min(cx + sample_radius + 1, width) + y0 = max(cy - sample_radius, 0) + y1 = min(cy + sample_radius + 1, height) + patch = reference_image[y0:y1, x0:x1] + patch_mask = eroded[y0:y1, x0:x1] + region = patch[patch_mask > 0] + if region.size == 0: + region = reference_image[mask == 255] + mean_bgr = region.mean(axis=0) + + r, g, b = [int(np.clip(c, 0, 255)) for c in mean_bgr[::-1]] + return f"#{r:02X}{g:02X}{b:02X}" + + # Draw lower levels first so higher ones sit on top + contours_with_levels_sorted = sorted(contours_with_levels, key=lambda x: x[1]) + + for i, (contour, level) in enumerate(contours_with_levels_sorted): + pts = contour.tolist() + if len(pts) < 3: + continue + + # Build a simple closed path (straight segments). Beziers look nice for strokes + # but can self-intersect when filled; straight segments are safer for fills. + d = [f"M {pts[0][0]},{pts[0][1]}"] + for j in range(1, len(pts)): + d.append(f"L {pts[j][0]},{pts[j][1]}") + d.append("Z") + path_data = " ".join(d) + + fallback = colors[i % len(colors)] + fill_color = color_from_image(np.array(pts), fallback) + + path = dwg.path( + d=path_data, + fill=fill_color, + fill_opacity=fill_opacity, + stroke=fill_color if draw_stroke else 'none', + stroke_opacity=stroke_opacity, + stroke_width=stroke_width, + ) + + # Helps when there are holes; keeps visual sane without hierarchy bookkeeping + path.update({'fill-rule': 'evenodd'}) + + dwg.add(path) + + dwg.save() + logger.info(f"Saved smooth filled contours to {output_path}") + + +def extract_marching_squares_contours( + image_path, + output_path='marching_squares.svg', + levels=None, + gaussian_kernel=(15, 15), + gaussian_sigma=3, + min_area=500, + smoothing_factor=0.08, + levels_mode='percentile', + percentile_values=(90, 95, 98), + min_intensity=1.0, + multi_otsu_classes=4, + hist_bins=512, + hist_sigma=2.0, + hist_variance_threshold=400.0, + hist_max_levels=5, + verbose=True, +): + """Extract contours using marching squares (skimage.find_contours).""" + img = cv2.imread(image_path) + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + blurred = cv2.GaussianBlur(gray, gaussian_kernel, gaussian_sigma) + + if levels is None: + mask = blurred > 0 + if not np.any(mask): + return [] + levels = compute_auto_levels( + blurred[mask], + mode=levels_mode, + percentile_values=percentile_values, + multi_otsu_classes=multi_otsu_classes, + min_intensity=min_intensity, + hist_bins=hist_bins, + hist_sigma=hist_sigma, + hist_variance_threshold=hist_variance_threshold, + hist_max_levels=hist_max_levels, + ) + if verbose: + logger.info(f"Marching squares levels ({levels_mode}): {levels}") + + marching_contours = [] + + for level in levels: + raw_contours = measure.find_contours(blurred, level=level) + for contour in raw_contours: + # skimage returns (row, col); flip to (x, y) + contour_xy = contour[:, ::-1] + if not np.array_equal(contour_xy[0], contour_xy[-1]): + contour_xy = np.vstack([contour_xy, contour_xy[0]]) + + if polygon_area(contour_xy) < min_area: + continue + + smooth = smooth_contour_spline(contour_xy, smoothing_factor=smoothing_factor) + marching_contours.append((smooth, level)) + + if marching_contours: + save_contours_to_svg(marching_contours, output_path, img.shape, reference_image=img) + + return sorted(marching_contours, key=lambda x: x[1], reverse=True) diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index 441b9fcc..d2993aeb 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -20,6 +20,8 @@ from bats_ai.core.models import CompressedSpectrogram from bats_ai.core.models.nabat import NABatCompressedSpectrogram +from .contour_utils import extract_marching_squares_contours + logger = logging.getLogger(__name__) FREQ_MIN = 5e3 @@ -29,12 +31,14 @@ class SpectrogramAssetResult(TypedDict): paths: list[str] + vectors: list[str] width: int height: int class SpectrogramCompressedAssetResult(TypedDict): paths: list[str] + vectors: list[str] width: int height: int widths: list[float] @@ -226,10 +230,10 @@ def generate_spectrogram_assets( os.path.splitext(os.path.basename(output_base))[0] + '_spectrogram', ) os.makedirs(os.path.dirname(normal_out_path_base), exist_ok=True) - normal_paths = save_img(normal_img_resized, normal_out_path_base) + normal_paths, vector_paths = save_img(normal_img_resized, normal_out_path_base) real_duration = math.ceil(duration * 1e3) - compressed_img, compressed_paths, widths, starts, stops = generate_compressed( - normal_img_resized, real_duration, output_base + compressed_img, compressed_paths, compressed_vector_paths, widths, starts, stops = ( + generate_compressed(normal_img_resized, real_duration, output_base) ) result = { @@ -238,11 +242,13 @@ def generate_spectrogram_assets( 'freq_max': freq_high, 'normal': { 'paths': normal_paths, + 'vectors': vector_paths, 'width': normal_img_resized.shape[1], 'height': normal_img_resized.shape[0], }, 'compressed': { 'paths': compressed_paths, + 'vectors': compressed_vector_paths, 'width': compressed_img.shape[1], 'height': compressed_img.shape[0], 'widths': widths, @@ -359,9 +365,9 @@ def generate_compressed(img: np.ndarray, duration: float, output_base: str): compressed_out_path = os.path.join(out_folder, f'{base_name}_compressed') # save_img should be your existing function to save images and return file paths - paths = save_img(compressed_img, compressed_out_path) + paths, vector_paths = save_img(compressed_img, compressed_out_path) - return compressed_img, paths, widths, starts_time, stops_time + return compressed_img, paths, vector_paths, widths, starts_time, stops_time def save_img(img: np.ndarray, output_base: str): @@ -374,6 +380,7 @@ def save_img(img: np.ndarray, output_base: str): ) total = len(chunks) output_paths = [] + output_svg_paths = [] for index, chunk in enumerate(chunks): out_path = f'{output_base}.{index + 1:02d}_of_{total:02d}.jpg' out_img = Image.fromarray(chunk, 'RGB') @@ -381,4 +388,12 @@ def save_img(img: np.ndarray, output_base: str): output_paths.append(out_path) logger.info(f'Saved image: {out_path}') - return output_paths + svg_path = f'{output_base}.{index + 1:02d}_of_{total:02d}.svg' + try: + extract_marching_squares_contours(out_path, svg_path) + output_svg_paths.append(svg_path) + logger.info(f'Saved SVG {svg_path}') + except Exception as e: + logger.error(f'Failed to create SVG for {out_path}. {e}') + + return output_paths, output_svg_paths diff --git a/pyproject.toml b/pyproject.toml index 6f24774a..3a381a2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ dependencies = [ # Production-only "gunicorn", "geopandas>=1.1.1", + "scikit-image>=0.25.2", + "svgwrite>=1.4.3", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 896e53b8..2f749a00 100644 --- a/uv.lock +++ b/uv.lock @@ -187,6 +187,8 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "rich" }, + { name = "scikit-image" }, + { name = "svgwrite" }, { name = "tqdm" }, { name = "whitenoise", extra = ["brotli"] }, ] @@ -273,6 +275,8 @@ requires-dist = [ { name = "psycopg", extras = ["binary"] }, { name = "pydantic" }, { name = "rich" }, + { name = "scikit-image", specifier = ">=0.25.2" }, + { name = "svgwrite", specifier = ">=1.4.3" }, { name = "tqdm" }, { name = "watchdog", marker = "extra == 'development'" }, { name = "werkzeug", marker = "extra == 'development'" }, @@ -1379,6 +1383,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "imageio" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -1966,6 +1983,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, +] + [[package]] name = "numba" version = "0.61.2" @@ -3008,6 +3059,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/17/22bf8155aa0ea2305eefa3a6402e040df7ebe512d1310165eda1e233c3f8/s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be", size = 85152, upload-time = "2025-05-22T19:24:48.703Z" }, ] +[[package]] +name = "scikit-image" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "tifffile", version = "2025.10.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/cb/016c63f16065c2d333c8ed0337e18a5cdf9bc32d402e4f26b0db362eb0e2/scikit_image-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3278f586793176599df6a4cf48cb6beadae35c31e58dc01a98023af3dc31c78", size = 13988922, upload-time = "2025-02-18T18:04:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/ff4731289cbed63c94a0c9a5b672976603118de78ed21910d9060c82e859/scikit_image-0.25.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c311069899ce757d7dbf1d03e32acb38bb06153236ae77fcd820fd62044c063", size = 13192698, upload-time = "2025-02-18T18:04:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/39/6d/a2aadb1be6d8e149199bb9b540ccde9e9622826e1ab42fe01de4c35ab918/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be455aa7039a6afa54e84f9e38293733a2622b8c2fb3362b822d459cc5605e99", size = 14153634, upload-time = "2025-02-18T18:04:18.496Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/916e7d9ee4721031b2f625db54b11d8379bd51707afaa3e5a29aecf10bc4/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c464b90e978d137330be433df4e76d92ad3c5f46a22f159520ce0fdbea8a09", size = 14767545, upload-time = "2025-02-18T18:04:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ee/c53a009e3997dda9d285402f19226fbd17b5b3cb215da391c4ed084a1424/scikit_image-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:60516257c5a2d2f74387c502aa2f15a0ef3498fbeaa749f730ab18f0a40fd054", size = 12812908, upload-time = "2025-02-18T18:04:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, + { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, + { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, + { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, +] + [[package]] name = "scikit-learn" version = "1.7.0" @@ -3346,6 +3439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload-time = "2024-10-30T16:01:28.003Z" }, ] +[[package]] +name = "svgwrite" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/c1/263d4e93b543390d86d8eb4fc23d9ce8a8d6efd146f9427364109004fa9b/svgwrite-1.4.3.zip", hash = "sha256:a8fbdfd4443302a6619a7f76bc937fc683daf2628d9b737c891ec08b8ce524c3", size = 189516, upload-time = "2022-07-14T14:05:26.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/15/640e399579024a6875918839454025bb1d5f850bb70d96a11eabb644d11c/svgwrite-1.4.3-py3-none-any.whl", hash = "sha256:bb6b2b5450f1edbfa597d924f9ac2dd099e625562e492021d7dd614f65f8a22d", size = 67122, upload-time = "2022-07-14T14:05:24.459Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -3367,6 +3469,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tifffile" +version = "2025.5.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/06/bd0a6097da704a7a7c34a94cfd771c3ea3c2f405dd214e790d22c93f6be1/tifffile-2025.5.10-py3-none-any.whl", hash = "sha256:e37147123c0542d67bc37ba5cdd67e12ea6fbe6e86c52bee037a9eb6a064e5ad", size = 226533, upload-time = "2025-05-10T19:22:27.279Z" }, +] + +[[package]] +name = "tifffile" +version = "2025.10.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/b5/0d8f3d395f07d25ec4cafcdfc8cab234b2cc6bf2465e9d7660633983fe8f/tifffile-2025.10.16.tar.gz", hash = "sha256:425179ec7837ac0e07bc95d2ea5bea9b179ce854967c12ba07fc3f093e58efc1", size = 371848, upload-time = "2025-10-16T22:56:09.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/5e/56c751afab61336cf0e7aa671b134255a30f15f59cd9e04f59c598a37ff5/tifffile-2025.10.16-py3-none-any.whl", hash = "sha256:41463d979c1c262b0a5cdef2a7f95f0388a072ad82d899458b154a48609d759c", size = 231162, upload-time = "2025-10-16T22:56:07.214Z" }, +] + [[package]] name = "tomli" version = "2.2.1" From f5da84beb19d9986bd541d13c907d0245bd45997 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Thu, 20 Nov 2025 10:35:33 -0500 Subject: [PATCH 3/9] Add migration and admin model --- bats_ai/core/admin/__init__.py | 2 ++ bats_ai/core/admin/spectrogram_svg.py | 22 ++++++++++++++ .../core/migrations/0023_spectrogramsvg.py | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 bats_ai/core/admin/spectrogram_svg.py create mode 100644 bats_ai/core/migrations/0023_spectrogramsvg.py diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index fe5a419e..1c1b3a57 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -18,6 +18,7 @@ from .species import SpeciesAdmin from .spectrogram import SpectrogramAdmin from .spectrogram_image import SpectrogramImageAdmin +from .spectrogram_svg import SpectrogramSvgAdmin __all__ = [ 'AnnotationsAdmin', @@ -34,6 +35,7 @@ 'ConfigurationAdmin', 'ExportedAnnotationFileAdmin', 'SpectrogramImageAdmin', + 'SpectrogramSvgAdmin', # NABat Models 'NABatRecordingAnnotationAdmin', 'NABatCompressedSpectrogramAdmin', diff --git a/bats_ai/core/admin/spectrogram_svg.py b/bats_ai/core/admin/spectrogram_svg.py new file mode 100644 index 00000000..26ad1ce1 --- /dev/null +++ b/bats_ai/core/admin/spectrogram_svg.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from bats_ai.core.models import SpectrogramSvg + + +@admin.register(SpectrogramSvg) +class SpectrogramSvgAdmin(admin.ModelAdmin): + list_display = [ + 'pk', + 'content_type', + 'object_id', + 'index', + 'image_file', + ] + list_select_related = True + readonly_fields = [ + 'pk', + 'content_type', + 'object_id', + 'index', + 'image_file', + ] diff --git a/bats_ai/core/migrations/0023_spectrogramsvg.py b/bats_ai/core/migrations/0023_spectrogramsvg.py new file mode 100644 index 00000000..38a158d5 --- /dev/null +++ b/bats_ai/core/migrations/0023_spectrogramsvg.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.23 on 2025-11-19 16:21 + +import bats_ai.core.models.spectrogram_vector +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0022_rename_temporalannotations_sequenceannotations'), + ] + + operations = [ + migrations.CreateModel( + name='SpectrogramSvg', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('type', models.CharField(choices=[('spectrogram', 'Spectrogram'), ('compressed', 'Compressed')], default='spectrogram', max_length=20)), + ('index', models.PositiveIntegerField()), + ('image_file', models.FileField(upload_to=bats_ai.core.models.spectrogram_vector.spectrogram_svg_upload_to)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + options={ + 'ordering': ['index'], + }, + ), + ] From 472a31a2d8039cd114138aa973321a0afce03de6 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Thu, 20 Nov 2025 10:35:46 -0500 Subject: [PATCH 4/9] Save contour image objects --- bats_ai/tasks/tasks.py | 23 +++++++++++++++++++++-- bats_ai/utils/contour_utils.py | 14 +++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/bats_ai/tasks/tasks.py b/bats_ai/tasks/tasks.py index 48703427..c758ccb4 100644 --- a/bats_ai/tasks/tasks.py +++ b/bats_ai/tasks/tasks.py @@ -62,8 +62,15 @@ def recording_compute_spectrogram(recording_id: int): for idx, svg_path in enumerate(results['normal']['vectors']): with open(svg_path, 'rb') as f: - # TODO - SpectrogramSvg.objects.get_or_create() + SpectrogramSvg.objects.get_or_create( + content_type=ContentType.objects.get_for_model(spectrogram), + object_id=spectrogram.id, + index=idx, + defaults={ + 'type': 'spectrogram', + 'image_file': File(f, name=os.path.basename(svg_path)), + }, + ) # Create or get CompressedSpectrogram @@ -93,6 +100,18 @@ def recording_compute_spectrogram(recording_id: int): }, ) + for idx, svg_path in enumerate(compressed['vectors']): + with open(svg_path, 'rb') as f: + SpectrogramSvg.objects.get_or_create( + content_type=ContentType.objects.get_for_model(compressed_obj), + object_id=compressed_obj.id, + index=idx, + defaults={ + 'image_file': File(f, name=os.path.basename(svg_path)), + 'type': 'compressed', + }, + ) + config = Configuration.objects.first() if config and config.run_inference_on_upload: predict_results = predict_from_compressed(compressed_obj) diff --git a/bats_ai/utils/contour_utils.py b/bats_ai/utils/contour_utils.py index 06cef1db..ef803b74 100644 --- a/bats_ai/utils/contour_utils.py +++ b/bats_ai/utils/contour_utils.py @@ -156,7 +156,12 @@ def polygon_area(points: np.ndarray) -> float: def smooth_contour_spline(contour, smoothing_factor=0.1): """Smooth contour using spline interpolation""" # Reshape contour - contour = contour.reshape(-1, 2) + if contour.ndim != 2 or contour.shape[1] != 2: + if contour.size % 2 == 0: + contour = contour.reshape(-1, 2) + else: + logger.warning(f'Invalid contour shape: {contour.shape}') + # contour = contour.reshape(-1, 2) # Close the contour by adding first point at end if not np.array_equal(contour[0], contour[-1]): @@ -178,8 +183,8 @@ def smooth_contour_spline(contour, smoothing_factor=0.1): tck, u = interpolate.splprep( [contour[:, 0], contour[:, 1]], s=len(contour) * smoothing_factor, per=True ) - smooth_points, _ = interpolate.splev(alpha, tck) - smooth_contour = np.column_stack(smooth_points) + x_smooth, y_smooth = interpolate.splev(alpha, tck) + smooth_contour = np.column_stack([x_smooth, y_smooth]) except Exception as e: # Fallback to simple smoothing if spline fails logger.info(f'Spline fitting failed {e}. Falling back to simple smoothing.') @@ -254,8 +259,10 @@ def color_from_image(points, fallback_color): # Draw lower levels first so higher ones sit on top contours_with_levels_sorted = sorted(contours_with_levels, key=lambda x: x[1]) + logger.info(f'Sorted contours length: {len(contours_with_levels_sorted)}') for i, (contour, level) in enumerate(contours_with_levels_sorted): + # logger.info(f'Attempting to add path for level {level}') pts = contour.tolist() if len(pts) < 3: continue @@ -348,6 +355,7 @@ def extract_marching_squares_contours( marching_contours.append((smooth, level)) if marching_contours: + logger.info(f'Saving contours to {output_path}') save_contours_to_svg(marching_contours, output_path, img.shape, reference_image=img) return sorted(marching_contours, key=lambda x: x[1], reverse=True) From 11e05714da153b9cc658057bca11fa008e04cf0e Mon Sep 17 00:00:00 2001 From: naglepuff Date: Wed, 3 Dec 2025 10:57:23 -0500 Subject: [PATCH 5/9] WIP toggle between vector and raster images --- bats_ai/core/models/compressed_spectrogram.py | 2 +- bats_ai/core/views/recording.py | 2 + client/src/api/api.ts | 1 + client/src/use/useState.ts | 7 +++ client/src/views/Spectrogram.vue | 55 +++++++++++++++---- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index d5bdbd32..90a01573 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -31,7 +31,7 @@ def image_url_list(self): return [default_storage.url(img.image_file.name) for img in images] @property - def vector_image_url_list(self): + def vector_url_list(self): images = self.vector_images.filter(type='compressed').order_by('index') return [default_storage.url(img.image_file.name) for img in images] diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index d4b5552f..55014b28 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -373,6 +373,7 @@ def get_spectrogram(request: HttpRequest, id: int): spectro_data = { 'urls': spectrogram.image_url_list, + 'vectors': spectrogram.vector_url_list, 'spectroInfo': { 'spectroId': spectrogram.pk, 'width': spectrogram.width, @@ -443,6 +444,7 @@ def get_spectrogram_compressed(request: HttpRequest, id: int): spectro_data = { 'urls': compressed_spectrogram.image_url_list, + 'vectors': compressed_spectrogram.vector_url_list, 'spectroInfo': { 'spectroId': compressed_spectrogram.pk, 'width': compressed_spectrogram.spectrogram.width, diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 3b13e655..eaa02a10 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -118,6 +118,7 @@ export interface UpdateFileAnnotation { export interface Spectrogram { urls: string[]; + vectors: string[]; filename?: string; annotations?: SpectrogramAnnotation[]; fileAnnotations: FileAnnotation[]; diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index b6250cbf..a55381f1 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -79,6 +79,11 @@ const toggleFixedAxes = () => { fixedAxes.value = !fixedAxes.value; }; +const viewContours = ref(false); +const toggleViewContours = () => { + viewContours.value = !viewContours.value; +}; + type AnnotationState = "" | "editing" | "creating" | "disabled"; export default function useState() { const setAnnotationState = (state: AnnotationState) => { @@ -191,5 +196,7 @@ export default function useState() { scaledHeight, fixedAxes, toggleFixedAxes, + viewContours, + toggleViewContours, }; } diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index abbcc4a2..9bc1fef0 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -13,6 +13,7 @@ import { getAnnotations, getSpectrogram, Species, + Spectrogram, getSpectrogramCompressed, getOtherUserAnnotations, getSequenceAnnotations, @@ -70,6 +71,8 @@ export default defineComponent({ toggleDrawingBoundingBox, fixedAxes, toggleFixedAxes, + viewContours, + toggleViewContours, } = useState(); const images: Ref = ref([]); const spectroInfo: Ref = ref(); @@ -114,15 +117,29 @@ export default defineComponent({ } }; + const loading = ref(false); + const spectrogramData: Ref = ref(null); + + /** + const createImages = () => { + if (!spectrogramData.value) return; + + + }; + */ + const loadData = async () => { loading.value = true; loadedImage.value = false; const response = compressed.value ? await getSpectrogramCompressed(props.id) : await getSpectrogram(props.id); - if (response.data.urls.length) { - const urls = response.data.urls; + spectrogramData.value = response.data; + if (spectrogramData.value.vectors.length) { + const urls = viewContours + ? spectrogramData.value.vectors + : spectrogramData.value.urls; images.value = []; allImagesLoaded.value = []; loadedImage.value = false; @@ -145,27 +162,27 @@ export default defineComponent({ console.error("No URL found for the spectrogram"); } spectroInfo.value = response.data["spectroInfo"]; - if (response.data['compressed'] && spectroInfo.value) { - spectroInfo.value.start_times = response.data.compressed.start_times; - spectroInfo.value.end_times = response.data.compressed.end_times; + if (spectrogramData.value['compressed'] && spectroInfo.value) { + spectroInfo.value.start_times = spectrogramData.value.compressed.start_times; + spectroInfo.value.end_times = spectrogramData.value.compressed.end_times; } annotations.value = - response.data["annotations"]?.sort( + spectrogramData.value["annotations"]?.sort( (a, b) => a.start_time - b.start_time ) || []; sequenceAnnotations.value = - response.data["sequence"]?.sort( + spectrogramData.value["sequence"]?.sort( (a, b) => a.start_time - b.start_time ) || []; - if (response.data.currentUser) { - currentUser.value = response.data.currentUser; + if (spectrogramData.value.currentUser) { + currentUser.value = spectrogramData.value.currentUser; } const speciesResponse = await getSpecies(); // Removing NOISE species from list and any duplicates speciesList.value = speciesResponse.data.filter( (value, index, self) => value.species_code !== "NOISE" && index === self.findIndex((t) => t.species_code === value.species_code) ); - if (response.data.otherUsers && spectroInfo.value) { + if (spectrogramData.value.otherUsers && spectroInfo.value) { // We have other users so we should grab the other user annotations const otherResponse = await getOtherUserAnnotations(props.id); otherUserAnnotations.value = otherResponse.data; @@ -301,6 +318,8 @@ export default defineComponent({ boundingBoxError, fixedAxes, toggleFixedAxes, + viewContours, + toggleViewContours, // Other user selection otherUserAnnotations, sequenceAnnotations, @@ -543,9 +562,23 @@ export default defineComponent({ Highlight Compressed Areas -
+
+ + + Toggle between smooth contour and raw image + From 9efabf493c3d75faf4a3d9c47e18b447b166fc01 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Fri, 5 Dec 2025 14:27:53 -0500 Subject: [PATCH 6/9] Add model for computed pulse annotations --- bats_ai/core/admin/__init__.py | 2 ++ bats_ai/core/admin/pulse_annotation.py | 13 ++++++++++ .../0024_computedpulseannotation.py | 25 +++++++++++++++++++ bats_ai/core/models/__init__.py | 2 ++ bats_ai/core/models/pulse_annotation.py | 11 ++++++++ 5 files changed, 53 insertions(+) create mode 100644 bats_ai/core/admin/pulse_annotation.py create mode 100644 bats_ai/core/migrations/0024_computedpulseannotation.py create mode 100644 bats_ai/core/models/pulse_annotation.py diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 1c1b3a57..e3212716 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -11,6 +11,7 @@ NABatSpectrogramAdmin, ) from .processing_task import ProcessingTaskAdmin +from .pulse_annotation import ComputedPulseAnnotationAdmin from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin from .recording_tag import RecordingTagAdmin @@ -41,4 +42,5 @@ 'NABatCompressedSpectrogramAdmin', 'NABatSpectrogramAdmin', 'NABatRecordingAdmin', + 'ComputedPulseAnnotationAdmin', ] diff --git a/bats_ai/core/admin/pulse_annotation.py b/bats_ai/core/admin/pulse_annotation.py new file mode 100644 index 00000000..6bb409fc --- /dev/null +++ b/bats_ai/core/admin/pulse_annotation.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from bats_ai.core.models import ComputedPulseAnnotation + + +@admin.register(ComputedPulseAnnotation) +class ComputedPulseAnnotationAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'recording', + 'bounding_box', + ] + list_select_related = True diff --git a/bats_ai/core/migrations/0024_computedpulseannotation.py b/bats_ai/core/migrations/0024_computedpulseannotation.py new file mode 100644 index 00000000..b3ea1880 --- /dev/null +++ b/bats_ai/core/migrations/0024_computedpulseannotation.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.23 on 2025-12-05 19:27 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0023_spectrogramsvg'), + ] + + operations = [ + migrations.CreateModel( + name='ComputedPulseAnnotation', + fields=[ + ('id', models.IntegerField(primary_key=True, serialize=False)), + ('index', models.IntegerField()), + ('bounding_box', django.contrib.gis.db.models.fields.PolygonField(srid=4326)), + ('contours', models.JSONField()), + ('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 600441d4..1b2b89a9 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -5,6 +5,7 @@ from .grts_cells import GRTSCells from .image import Image from .processing_task import ProcessingTask, ProcessingTaskType +from .pulse_annotation import ComputedPulseAnnotation from .recording import Recording, RecordingTag from .recording_annotation import RecordingAnnotation from .recording_annotation_status import RecordingAnnotationStatus @@ -32,4 +33,5 @@ 'ExportedAnnotationFile', 'SpectrogramImage', 'SpectrogramSvg', + 'ComputedPulseAnnotation', ] diff --git a/bats_ai/core/models/pulse_annotation.py b/bats_ai/core/models/pulse_annotation.py new file mode 100644 index 00000000..4c3ed624 --- /dev/null +++ b/bats_ai/core/models/pulse_annotation.py @@ -0,0 +1,11 @@ +from django.contrib.gis.db import models + +from .recording import Recording + + +class ComputedPulseAnnotation(models.Model): + id = models.IntegerField(primary_key=True) + 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() From bc8722e4674e90260420c5436eb51ce57961d14f Mon Sep 17 00:00:00 2001 From: naglepuff Date: Tue, 9 Dec 2025 09:03:25 -0500 Subject: [PATCH 7/9] Generate contours for each pulse --- .../0024_computedpulseannotation.py | 25 ---------- ...spectrogramsvg_computedpulseannotation.py} | 15 +++++- bats_ai/core/models/pulse_annotation.py | 1 - bats_ai/core/views/recording.py | 39 +++++++++++++++- bats_ai/tasks/tasks.py | 39 +++++++++++++++- bats_ai/utils/contour_utils.py | 4 +- bats_ai/utils/spectrogram_utils.py | 46 ++++++++++++++++++- 7 files changed, 135 insertions(+), 34 deletions(-) delete mode 100644 bats_ai/core/migrations/0024_computedpulseannotation.py rename bats_ai/core/migrations/{0023_spectrogramsvg.py => 0024_spectrogramsvg_computedpulseannotation.py} (61%) diff --git a/bats_ai/core/migrations/0024_computedpulseannotation.py b/bats_ai/core/migrations/0024_computedpulseannotation.py deleted file mode 100644 index b3ea1880..00000000 --- a/bats_ai/core/migrations/0024_computedpulseannotation.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.2.23 on 2025-12-05 19:27 - -import django.contrib.gis.db.models.fields -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0023_spectrogramsvg'), - ] - - operations = [ - migrations.CreateModel( - name='ComputedPulseAnnotation', - fields=[ - ('id', models.IntegerField(primary_key=True, serialize=False)), - ('index', models.IntegerField()), - ('bounding_box', django.contrib.gis.db.models.fields.PolygonField(srid=4326)), - ('contours', models.JSONField()), - ('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.recording')), - ], - ), - ] diff --git a/bats_ai/core/migrations/0023_spectrogramsvg.py b/bats_ai/core/migrations/0024_spectrogramsvg_computedpulseannotation.py similarity index 61% rename from bats_ai/core/migrations/0023_spectrogramsvg.py rename to bats_ai/core/migrations/0024_spectrogramsvg_computedpulseannotation.py index 38a158d5..22e17053 100644 --- a/bats_ai/core/migrations/0023_spectrogramsvg.py +++ b/bats_ai/core/migrations/0024_spectrogramsvg_computedpulseannotation.py @@ -1,6 +1,7 @@ -# Generated by Django 4.2.23 on 2025-11-19 16:21 +# Generated by Django 4.2.23 on 2025-12-08 22:19 import bats_ai.core.models.spectrogram_vector +import django.contrib.gis.db.models.fields from django.db import migrations, models import django.db.models.deletion @@ -9,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('core', '0022_rename_temporalannotations_sequenceannotations'), + ('core', '0023_recordingtag_recording_tags_and_more'), ] operations = [ @@ -27,4 +28,14 @@ class Migration(migrations.Migration): 'ordering': ['index'], }, ), + migrations.CreateModel( + name='ComputedPulseAnnotation', + 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()), + ('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.recording')), + ], + ), ] diff --git a/bats_ai/core/models/pulse_annotation.py b/bats_ai/core/models/pulse_annotation.py index 4c3ed624..fbe519fb 100644 --- a/bats_ai/core/models/pulse_annotation.py +++ b/bats_ai/core/models/pulse_annotation.py @@ -4,7 +4,6 @@ class ComputedPulseAnnotation(models.Model): - id = models.IntegerField(primary_key=True) recording = models.ForeignKey(Recording, on_delete=models.CASCADE) index = models.IntegerField(null=False, blank=False) bounding_box = models.PolygonField(null=False, blank=False) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 55014b28..997a111f 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, List, Optional 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, + ComputedPulseAnnotation, Recording, RecordingAnnotation, RecordingTag, @@ -127,6 +128,22 @@ class UpdateAnnotationsSchema(Schema): id: int | None +class ComputedPulseAnnotationSchema(Schema): + id: int | None + index: int + bounding_box: Any + contours: list + + @classmethod + def from_orm(cls, obj: ComputedPulseAnnotation): + return cls( + id=obj.id, + index=obj.index, + contours=obj.contours, + bounding_box=json.loads(obj.bounding_box.geojson) + ) + + @router.post('/') def create_recording( request: HttpRequest, @@ -528,6 +545,26 @@ 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 = ComputedPulseAnnotation.objects.filter( + recording=recording + ).order_by('index') + return [ + ComputedPulseAnnotationSchema.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/tasks/tasks.py b/bats_ai/tasks/tasks.py index c758ccb4..7e645258 100644 --- a/bats_ai/tasks/tasks.py +++ b/bats_ai/tasks/tasks.py @@ -5,10 +5,12 @@ from PIL import Image from celery import shared_task from django.contrib.contenttypes.models import ContentType +from django.contrib.gis.geos import Polygon from django.core.files import File from bats_ai.core.models import ( CompressedSpectrogram, + ComputedPulseAnnotation, Configuration, Recording, RecordingAnnotation, @@ -72,7 +74,6 @@ def recording_compute_spectrogram(recording_id: int): }, ) - # Create or get CompressedSpectrogram compressed = results['compressed'] compressed_obj, _ = CompressedSpectrogram.objects.get_or_create( @@ -112,6 +113,42 @@ def recording_compute_spectrogram(recording_id: int): }, ) + # Generate computed annotations for contours + logger.info( + "Adding contour and bounding boxes for " + f"{len(results.get('contours', []))} pulses" + ) + for idx, contour in enumerate(results.get('contours', [])): + # Transform contour (x, y) pairs into (time, freq) pairs + widths, starts, stops = compressed['widths'], compressed['starts'], compressed['stops'] + start_time = starts[idx] + end_time = stops[idx] + width = widths[idx] + time_per_pixel = (end_time - start_time) / width + mhz_per_pixel = (results['freq_max'] - results['freq_min']) / compressed['height'] + transformed_lines = [] + for contour_line in contour: + new_curve = [ + [point[0] * time_per_pixel, point[1] * mhz_per_pixel] + for point in contour_line["curve"] + ] + transformed_lines.append({ + "curve": new_curve, + "level": contour_line["level"], + }) + ComputedPulseAnnotation.objects.get_or_create( + index=idx, + recording=recording, + contours=transformed_lines, + bounding_box=Polygon(( + (start_time, results['freq_max']), + (end_time, results['freq_max']), + (end_time, results['freq_min']), + (start_time, results['freq_min']), + (start_time, results['freq_max']), + )), + ) + config = Configuration.objects.first() if config and config.run_inference_on_upload: predict_results = predict_from_compressed(compressed_obj) diff --git a/bats_ai/utils/contour_utils.py b/bats_ai/utils/contour_utils.py index ef803b74..421094e0 100644 --- a/bats_ai/utils/contour_utils.py +++ b/bats_ai/utils/contour_utils.py @@ -2,7 +2,6 @@ import cv2 import numpy as np -from scipy import ndimage from scipy.ndimage import gaussian_filter1d from skimage import measure from skimage.filters import threshold_multiotsu @@ -312,6 +311,7 @@ def extract_marching_squares_contours( hist_sigma=2.0, hist_variance_threshold=400.0, hist_max_levels=5, + save_to_file=True, verbose=True, ): """Extract contours using marching squares (skimage.find_contours).""" @@ -354,7 +354,7 @@ def extract_marching_squares_contours( smooth = smooth_contour_spline(contour_xy, smoothing_factor=smoothing_factor) marching_contours.append((smooth, level)) - if marching_contours: + if marching_contours and save_to_file: logger.info(f'Saving contours to {output_path}') save_contours_to_svg(marching_contours, output_path, img.shape, reference_image=img) diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index d2993aeb..cd2ca2c6 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -4,6 +4,7 @@ import math import os from pathlib import Path +import tempfile from typing import TypedDict from PIL import Image @@ -46,12 +47,18 @@ class SpectrogramCompressedAssetResult(TypedDict): stops: list[float] +class Contour(TypedDict): + curve: list[list[int | float]] + level: int | float + + class SpectrogramAssets(TypedDict): duration: float freq_min: int freq_max: int normal: SpectrogramAssetResult compressed: SpectrogramCompressedAssetResult + contours: list[list[Contour]] class PredictionOutput(TypedDict): @@ -232,7 +239,15 @@ def generate_spectrogram_assets( os.makedirs(os.path.dirname(normal_out_path_base), exist_ok=True) normal_paths, vector_paths = save_img(normal_img_resized, normal_out_path_base) real_duration = math.ceil(duration * 1e3) - compressed_img, compressed_paths, compressed_vector_paths, widths, starts, stops = ( + ( + compressed_img, + compressed_paths, + compressed_vector_paths, + widths, + starts, + stops, + contours, + ) = ( generate_compressed(normal_img_resized, real_duration, output_base) ) @@ -256,10 +271,35 @@ def generate_spectrogram_assets( 'stops': stops, }, } + if contours: + result["contours"] = contours return result +def generate_pulse_contours(segments: list[np.ndarray], widths: list): + logger.info(f"Generating pulse contours for {len(segments)} pulses") + contours = [] + with tempfile.TemporaryDirectory() as tmpdir: + for index, segment in enumerate(segments): + # Save the NDArray as a file in the tempdir + out_img = Image.fromarray(segment, "RGB") + segment_path = f"{tmpdir}/{index}.jpg" + out_img.save(segment_path, format="JPEG", optimize=True, quality=80) + # Generate marching square contours from temp file + np_contours = extract_marching_squares_contours( + segment_path, + "", + save_to_file=False + ) + logger.info(f"Generated {len(np_contours)} for pulse {index}") + segment_contours = [ + {"curve": c[0].tolist(), "level": c[1]} for c in np_contours + ] + contours.append(segment_contours) + return contours + + def generate_compressed(img: np.ndarray, duration: float, output_base: str): threshold = 0.5 compressed_img = img.copy() @@ -343,7 +383,9 @@ def generate_compressed(img: np.ndarray, duration: float, output_base: str): segments.append(segment) widths.append(stop_clamped - start_clamped) + contours = [] if segments: + contours = generate_pulse_contours(segments, widths) compressed_img = np.hstack(segments) break @@ -367,7 +409,7 @@ def generate_compressed(img: np.ndarray, duration: float, output_base: str): # save_img should be your existing function to save images and return file paths paths, vector_paths = save_img(compressed_img, compressed_out_path) - return compressed_img, paths, vector_paths, widths, starts_time, stops_time + return compressed_img, paths, vector_paths, widths, starts_time, stops_time, contours def save_img(img: np.ndarray, output_base: str): From 026d9932b5bb2d2391ddff343a8fb9881354df79 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Wed, 17 Dec 2025 12:28:55 -0500 Subject: [PATCH 8/9] Fetch and display pulse contours --- client/src/api/api.ts | 19 ++ client/src/components/SpectrogramViewer.vue | 1 + client/src/components/ThumbnailViewer.vue | 3 +- client/src/components/geoJS/LayerManager.vue | 38 +++- .../components/geoJS/layers/contourLayer.ts | 175 ++++++++++++++++++ client/src/use/useState.ts | 19 +- client/src/views/Spectrogram.vue | 16 +- 7 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 client/src/components/geoJS/layers/contourLayer.ts diff --git a/client/src/api/api.ts b/client/src/api/api.ts index eaa02a10..9c0013ff 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -132,6 +132,7 @@ export interface Spectrogram { otherUsers?: UserInfo[]; } + export type OtherUserAnnotations = Record< string, { annotations: SpectrogramAnnotation[]; sequence: SpectrogramSequenceAnnotation[] } @@ -507,6 +508,23 @@ async function getExportStatus(exportId: number) { return result.data; } +export interface Contour { + curve: number[][]; + level: number; + index: number; +} + +export interface ComputedPulseAnnotation { + id: number; + index: number; + contours: Contour[]; +} + +async function getComputedPulseAnnotations(recordingId: number) { + const result = await axiosInstance.get(`/recording/${recordingId}/pulse_data`); + return result.data; +} + export { uploadRecordingFile, getRecordings, @@ -541,4 +559,5 @@ export { getFileAnnotationDetails, getExportStatus, getRecordingTags, + getComputedPulseAnnotations, }; diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index b4248dfc..3a7ff337 100644 --- a/client/src/components/SpectrogramViewer.vue +++ b/client/src/components/SpectrogramViewer.vue @@ -267,6 +267,7 @@ export default defineComponent({ :spectro-info="spectroInfo" :scaled-width="scaledWidth" :scaled-height="scaledHeight" + :recording-id="recordingId" @update:annotation="updateAnnotation($event)" @create:annotation="createAnnotation($event)" @set-cursor="setCursor($event)" diff --git a/client/src/components/ThumbnailViewer.vue b/client/src/components/ThumbnailViewer.vue index 716b66ec..5478eab8 100644 --- a/client/src/components/ThumbnailViewer.vue +++ b/client/src/components/ThumbnailViewer.vue @@ -165,6 +165,7 @@ export default defineComponent({ :spectro-info="spectroInfo" :scaled-width="scaledWidth" :scaled-height="scaledHeight" + :recording-id="recordingId" thumbnail @selected="$emit('selected',$event)" /> @@ -189,7 +190,7 @@ export default defineComponent({ margin:2px; &.geojs-map:focus { outline: none; - } + } } .playback-container { diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 266952a0..f75dfe90 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -1,7 +1,7 @@