diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index bafa70f5..0e84b36b 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_annotation import ComputedPulseAnnotationAdmin from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin from .recording_tag import RecordingTagAdmin @@ -39,4 +40,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/0026_computedpulseannotation.py b/bats_ai/core/migrations/0026_computedpulseannotation.py new file mode 100644 index 00000000..f59b2b30 --- /dev/null +++ b/bats_ai/core/migrations/0026_computedpulseannotation.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.23 on 2026-01-21 22:46 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0025_configuration_mark_annotations_completed_enabled_and_more'), + ] + + operations = [ + 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/__init__.py b/bats_ai/core/models/__init__.py index 8509896a..05f2a422 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_annotation import ComputedPulseAnnotation from .recording import Recording, RecordingTag from .recording_annotation import RecordingAnnotation from .recording_annotation_status import RecordingAnnotationStatus @@ -29,5 +30,6 @@ 'ProcessingTaskType', 'ExportedAnnotationFile', 'SpectrogramImage', + 'ComputedPulseAnnotation', 'VettingDetails', ] diff --git a/bats_ai/core/models/pulse_annotation.py b/bats_ai/core/models/pulse_annotation.py new file mode 100644 index 00000000..fbe519fb --- /dev/null +++ b/bats_ai/core/models/pulse_annotation.py @@ -0,0 +1,10 @@ +from django.contrib.gis.db import models + +from .recording import Recording + + +class ComputedPulseAnnotation(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() diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index ef0d5097..bdc98e1f 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -3,11 +3,13 @@ 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, + ComputedPulseAnnotation, Configuration, Recording, RecordingAnnotation, @@ -78,6 +80,45 @@ 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 + start_time, + results['freq_max'] - (point[1] * mhz_per_pixel), + ] + for point in contour_line['curve'] + ] + transformed_lines.append( + {'curve': new_curve, 'level': contour_line['level'], 'index': idx} + ) + 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/core/views/recording.py b/bats_ai/core/views/recording.py index a97dfd66..f9e3f44b 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, @@ -129,6 +130,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, @@ -389,6 +406,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, @@ -459,6 +477,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, @@ -542,6 +561,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/utils/contour_utils.py b/bats_ai/utils/contour_utils.py new file mode 100644 index 00000000..2da2680d --- /dev/null +++ b/bats_ai/utils/contour_utils.py @@ -0,0 +1,361 @@ +import logging + +import cv2 +import numpy as np +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 + 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]): + 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 + ) + 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.') + 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]) + 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 + + # 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, + save_to_file=True, + 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 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) + + 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..16f14baa 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 @@ -20,6 +21,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 +32,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] @@ -42,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): @@ -226,11 +237,17 @@ 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, + contours, + ) = generate_compressed(normal_img_resized, real_duration, output_base) result = { 'duration': real_duration, @@ -238,11 +255,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, @@ -250,10 +269,29 @@ 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() @@ -337,7 +375,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 @@ -359,9 +399,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, contours def save_img(img: np.ndarray, output_base: str): @@ -374,6 +414,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 +422,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/client/src/api/api.ts b/client/src/api/api.ts index 102f3462..59c6c151 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -120,6 +120,7 @@ export interface UpdateFileAnnotation { export interface Spectrogram { urls: string[]; + vectors: string[]; filename?: string; annotations?: SpectrogramAnnotation[]; fileAnnotations: FileAnnotation[]; @@ -133,6 +134,7 @@ export interface Spectrogram { otherUsers?: UserInfo[]; } + export type OtherUserAnnotations = Record< string, { annotations: SpectrogramAnnotation[]; sequence: SpectrogramSequenceAnnotation[] } @@ -537,6 +539,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 interface VettingDetails { id: number; user_id: number; @@ -603,6 +622,7 @@ export { getFileAnnotationDetails, getExportStatus, getRecordingTags, + getComputedPulseAnnotations, getCurrentUser, getVettingDetailsForUser, createOrUpdateVettingDetailsForUser, 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..b7ce5236 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -20,8 +20,10 @@ import SpeciesSequenceLayer from "./layers/speciesSequenceLayer"; import MeasureToolLayer from "./layers/measureToolLayer"; import BoundingBoxLayer from "./layers/boundingBoxLayer"; import AxesLayer from "./layers/axesLayer"; +import ContourLayer from "./layers/contourLayer"; import { cloneDeep } from "lodash"; import useState from "@use/useState"; + export default defineComponent({ name: "LayerManager", props: { @@ -45,7 +47,11 @@ export default defineComponent({ scaledHeight: { type: Number, default: -1, - } + }, + recordingId: { + type: String as PropType, + required: true, + }, }, emits: ["selected", "update:annotation", "create:annotation", "set-cursor"], setup(props, { emit }) { @@ -72,6 +78,9 @@ export default defineComponent({ drawingBoundingBox, boundingBoxError, fixedAxes, + viewContours, + loadContours, + computedPulseAnnotations, } = useState(); const selectedAnnotationId: Ref = ref(null); const hoveredAnnotationId: Ref = ref(null); @@ -91,6 +100,7 @@ export default defineComponent({ let speciesSequenceLayer: SpeciesSequenceLayer; let measureToolLayer: MeasureToolLayer; let boundingBoxLayer: BoundingBoxLayer; + let contourLayer: ContourLayer; const displayError = ref(false); const errorMsg = ref(""); @@ -445,6 +455,34 @@ export default defineComponent({ triggerUpdate(); } ); + watch(() => props.recordingId, () => computedPulseAnnotations.value = []); + watch(viewContours, async () => { + if (props.thumbnail) { + return; + } + if (!props.recordingId || !props.spectroInfo) { + console.error('Could not load contours. Could not determine recording ID'); + return; + } + if (computedPulseAnnotations.value.length === 0) { + await loadContours(new Number(props.recordingId) as number); + } + if (!contourLayer) { + contourLayer = new ContourLayer( + props.geoViewerRef, + event, + props.spectroInfo, + computedPulseAnnotations.value, + colorScheme.value.scheme, + ); + } + contourLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); + if (viewContours.value) { + contourLayer.drawContours(); + } else { + contourLayer.removeContours(); + } + }); onUnmounted(() => { if (editAnnotationLayer) { editAnnotationLayer.destroy(); @@ -699,6 +737,7 @@ export default defineComponent({ // Triggers the Axis redraw when zoomed in and the axis is at the bottom/top legendLayer?.onPan(); axesLayer?.setScaledDimensions(props.scaledWidth, props.scaledHeight); + contourLayer?.setScaledDimensions(props.scaledWidth, props.scaledHeight); }); watch(viewCompressedOverlay, () => { if (viewCompressedOverlay.value && compressedOverlayLayer && props.spectroInfo?.start_times && props.spectroInfo.end_times) { @@ -771,6 +810,9 @@ export default defineComponent({ if (boundingBoxLayer) { boundingBoxLayer.setTextColor(textColor); } + if (contourLayer) { + contourLayer.setColorScheme(colorScheme.value.scheme); + } } watch([backgroundColor, colorScheme], updateColorFilter); diff --git a/client/src/components/geoJS/layers/contourLayer.ts b/client/src/components/geoJS/layers/contourLayer.ts new file mode 100644 index 00000000..4aeb4cec --- /dev/null +++ b/client/src/components/geoJS/layers/contourLayer.ts @@ -0,0 +1,193 @@ +import { SpectroInfo } from '../geoJSUtils'; +import { + ComputedPulseAnnotation, + Contour, +} from '@api/api'; + +interface ContourPoint { + x: number; + y: number; + z: number; +} + +export default class ContourLayer { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: (name: string, data: any) => void; + + spectroInfo: SpectroInfo; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + contourLayer: any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + features: any[]; + + colorScheme: (t: number) => string; + + maxLevel: number; + + scaledHeight: number; + + scaledWidth: number; + + computedPulseAnnotations: ComputedPulseAnnotation[]; + + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: (name: string, data: any) => void, + spectroInfo: SpectroInfo, + computedPulseAnnotations: ComputedPulseAnnotation[], + colorScheme: (t: number) => string, + ) { + this.geoViewerRef = geoViewerRef; + this.event = event; + this.spectroInfo = spectroInfo; + this.scaledHeight = this.spectroInfo.height; + this.scaledWidth = this.spectroInfo.width; + this.colorScheme = colorScheme; + this.computedPulseAnnotations = computedPulseAnnotations; + this.features = []; + this.maxLevel = 0; + this.init(); + } + + setScaledDimensions(scaledWidth: number, scaledHeight: number) { + this.scaledWidth = scaledWidth; + this.scaledHeight = scaledHeight; + this.removeContours(); + this.drawContours(); + } + + init() { + if (!this.contourLayer) { + this.contourLayer = this.geoViewerRef.createLayer('feature'); + } + } + + destroy() { + if (this.contourLayer) { + this.geoViewerRef.deleteLayer(this.contourLayer); + } + } + + drawPolygonsForPulse(pulse: Contour[]) { + const polyData: number[][][] = []; + pulse.sort((a: Contour, b: Contour) => { + return a.level - b.level; + }).forEach((contour: Contour) => { + const newPoly: number[][] = []; + contour.curve.forEach((point: number[]) => { + const contourPoint = this.getTransformedContourPoint(point, contour.level, contour.index); + newPoly.push([contourPoint.x, contourPoint.y, contour.level]); + }); + polyData.push(newPoly); + }); + const polygonFeature = this.contourLayer.createFeature('polygon'); + polygonFeature + .data(polyData) + .position((item: number[]) => ({ x: item[0], y: item[1] })) + .style(this.getContourStyle()) + .draw(); + this.features.push(polygonFeature); + } + + removeContours() { + if (!this.contourLayer) return; + this.features.forEach((feature) => { + feature.data([]).draw(); + }); + this.contourLayer.draw(); + } + + drawContours() { + this.computedPulseAnnotations.forEach((annotation: ComputedPulseAnnotation) => annotation.contours.forEach((contour: Contour) => { + if (contour.level > this.maxLevel) { + this.maxLevel = contour.level; + } + })); + this.computedPulseAnnotations.forEach((pulseAnnotation: ComputedPulseAnnotation) => this.drawPolygonsForPulse(pulseAnnotation.contours)); + } + + getTransformedContourPoint(point: number[], level: number, index: number): ContourPoint { + if (this.spectroInfo.compressedWidth) { + return this._getTransformedContourPointCompressed(point, level, index); + } + return this._getTransformedContourPoint(point, level); + } + + _getYValueFromFrequency(freq: number) { + const freqRange = this.spectroInfo.high_freq - this.spectroInfo.low_freq; + const height = Math.max(this.scaledHeight, this.spectroInfo.height); + const pixelsPerMhz = height / freqRange; + return (this.spectroInfo.high_freq - freq) * pixelsPerMhz; + } + + _getTransformedContourPointCompressed(point: number[], level: number, index: number): ContourPoint { + if ( + !this.spectroInfo.start_times + || !this.spectroInfo.end_times + || !this.spectroInfo.widths + || !this.spectroInfo.compressedWidth + ) { + // Dummy value + return { x: 0, y: 0, z: 0 }; + } + const scaleFactor = this.scaledWidth / this.spectroInfo.compressedWidth; + // Find the segment containing the target time + const startTime = this.spectroInfo.start_times[index]; + const endTime = this.spectroInfo.end_times[index]; + // If the time for the given point exceeds the end time of the specified segment, clamp to the segment + const targetTime = Math.min(point[0], endTime); + // Get the width of the segment + const width = this.spectroInfo.widths[index]; + // Get the offset of the segment + let segmentOffset = 0; + for (let i = 0; i < index; i++) { + segmentOffset += (this.spectroInfo.widths[i] * scaleFactor); + } + // Find the pixelsPerMs and pixel offset + const pixelsPerMs = width / (endTime - startTime); + // Calculate final X position for the given time + const xVal = segmentOffset + ((targetTime - startTime) * pixelsPerMs * scaleFactor); + return { + x: xVal, + y: this._getYValueFromFrequency(point[1]), + z: level + }; + } + + _getTransformedContourPoint(point: number[], level: number,): ContourPoint { + const width = Math.max(this.spectroInfo.width, this.scaledWidth); + const timeRange = this.spectroInfo.end_time - this.spectroInfo.start_time; + const pixelsPerMs = width / timeRange; + const timeOffset = point[0] - this.spectroInfo.start_time; + return { + x: timeOffset * pixelsPerMs, + y: this._getYValueFromFrequency(point[1]), + z: level + }; + } + + getContourStyle() { + return { + uniformPolygon: true, + stroke: false, + fillColor: (_val: number, _idx: number, coords: number[][]) => { + return this.colorScheme((coords[0][2] || 0) / this.maxLevel); + }, + fillOpacity: 1.0, + }; + } + + setColorScheme(colorScheme: (t: number) => string) { + this.colorScheme = colorScheme; + // Redraw + this.removeContours(); + this.drawContours(); + } +} diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index ab55f8eb..01b1afb4 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -11,6 +11,8 @@ import { SpectrogramAnnotation, SpectrogramSequenceAnnotation, RecordingTag, + getComputedPulseAnnotations, + ComputedPulseAnnotation, FileAnnotation, getVettingDetailsForUser, } from "../api/api"; @@ -86,6 +88,21 @@ const toggleFixedAxes = () => { fixedAxes.value = !fixedAxes.value; }; +const computedPulseAnnotations: Ref = ref([]); +const viewContours = ref(false); +const contoursLoading = ref(false); +const toggleViewContours = () => { + viewContours.value = !viewContours.value; +}; +async function loadContours(recordingId: number) { + contoursLoading.value = true; + computedPulseAnnotations.value = await getComputedPulseAnnotations(recordingId); + contoursLoading.value = false; +} +function clearContours() { + computedPulseAnnotations.value = []; +} + const reviewerMaterials = ref(''); type AnnotationState = "" | "editing" | "creating" | "disabled"; @@ -346,6 +363,12 @@ export default function useState() { scaledHeight, fixedAxes, toggleFixedAxes, + viewContours, + contoursLoading, + toggleViewContours, + loadContours, + clearContours, + computedPulseAnnotations, showSubmittedRecordings, submittedMyRecordings, submittedSharedRecordings, diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index 0d0a1ccb..2e34df7b 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -14,6 +14,7 @@ import { getAnnotations, getSpectrogram, Species, + Spectrogram, getSpectrogramCompressed, getOtherUserAnnotations, getSequenceAnnotations, @@ -73,6 +74,10 @@ export default defineComponent({ toggleDrawingBoundingBox, fixedAxes, toggleFixedAxes, + viewContours, + contoursLoading, + toggleViewContours, + clearContours, nextUnsubmittedRecordingId, previousUnsubmittedRecordingId, currentRecordingId, @@ -121,16 +126,31 @@ export default defineComponent({ } }; + const loading = ref(false); + const spectrogramData: Ref = ref(null); + + /** + const createImages = () => { + if (!spectrogramData.value) return; + + + }; + */ + const loadData = async () => { loading.value = true; currentRecordingId.value = parseInt(props.id); loadedImage.value = false; + clearContours(); 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.urls // vectors + : spectrogramData.value.urls; images.value = []; allImagesLoaded.value = []; loadedImage.value = false; @@ -153,27 +173,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) => 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; @@ -318,6 +338,9 @@ export default defineComponent({ boundingBoxError, fixedAxes, toggleFixedAxes, + viewContours, + toggleViewContours, + contoursLoading, // Other user selection otherUserAnnotations, sequenceAnnotations, @@ -564,9 +587,31 @@ export default defineComponent({ Highlight Compressed Areas -
+
+ + + Toggle between smooth contour and raw image + diff --git a/pyproject.toml b/pyproject.toml index 2565979c..6b888d78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,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 319d3a5e..142e7a66 100644 --- a/uv.lock +++ b/uv.lock @@ -188,6 +188,8 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "rich" }, + { name = "scikit-image" }, + { name = "svgwrite" }, { name = "tqdm" }, { name = "whitenoise", extra = ["brotli"] }, ] @@ -275,6 +277,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'" }, @@ -1402,6 +1406,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" @@ -1989,6 +2006,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" @@ -3031,6 +3082,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" @@ -3369,6 +3462,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" @@ -3390,6 +3492,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"