From 5ffb23a68e8cf2e0f2f32a809c09a94149c1a15c Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 14:46:12 -0500 Subject: [PATCH 1/5] batbot metadata graphing --- bats_ai/core/admin/pulse_metadata.py | 2 +- ..._char_freq_pulsemetadata_curve_and_more.py | 35 ++++ bats_ai/core/models/pulse_metadata.py | 5 +- bats_ai/core/tasks/nabat/tasks.py | 45 +++- bats_ai/core/tasks/tasks.py | 31 ++- bats_ai/core/utils/batbot_metadata.py | 41 +++- bats_ai/core/views/recording.py | 51 ++++- client/src/api/api.ts | 23 +- client/src/components/PulseMetadataButton.vue | 189 +++++++++++++++++ client/src/components/geoJS/LayerManager.vue | 89 +++++++- .../components/geoJS/layers/contourLayer.ts | 12 +- .../geoJS/layers/pulseMetadataLayer.ts | 196 ++++++++++++++++++ client/src/use/useState.ts | 52 ++++- client/src/views/Spectrogram.vue | 26 ++- 14 files changed, 760 insertions(+), 37 deletions(-) create mode 100644 bats_ai/core/migrations/0029_pulsemetadata_char_freq_pulsemetadata_curve_and_more.py create mode 100644 client/src/components/PulseMetadataButton.vue create mode 100644 client/src/components/geoJS/layers/pulseMetadataLayer.ts diff --git a/bats_ai/core/admin/pulse_metadata.py b/bats_ai/core/admin/pulse_metadata.py index d1d07e25..b6ba8922 100644 --- a/bats_ai/core/admin/pulse_metadata.py +++ b/bats_ai/core/admin/pulse_metadata.py @@ -5,5 +5,5 @@ @admin.register(PulseMetadata) class PulseMetadataAdmin(admin.ModelAdmin): - list_display = ('recording', 'index', 'bounding_box') + list_display = ('recording', 'index', 'bounding_box', 'curve', 'char_freq', 'knee', 'heel') list_select_related = True diff --git a/bats_ai/core/migrations/0029_pulsemetadata_char_freq_pulsemetadata_curve_and_more.py b/bats_ai/core/migrations/0029_pulsemetadata_char_freq_pulsemetadata_curve_and_more.py new file mode 100644 index 00000000..e4d080fd --- /dev/null +++ b/bats_ai/core/migrations/0029_pulsemetadata_char_freq_pulsemetadata_curve_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.23 on 2026-01-30 17:55 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0028_alter_spectrogramimage_type'), + ] + + operations = [ + migrations.AddField( + model_name='pulsemetadata', + name='char_freq', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='pulsemetadata', + name='curve', + field=django.contrib.gis.db.models.fields.LineStringField( + blank=True, null=True, srid=4326 + ), + ), + migrations.AddField( + model_name='pulsemetadata', + name='heel', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + migrations.AddField( + model_name='pulsemetadata', + name='knee', + field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), + ), + ] diff --git a/bats_ai/core/models/pulse_metadata.py b/bats_ai/core/models/pulse_metadata.py index fe8dc3b3..1cf1df36 100644 --- a/bats_ai/core/models/pulse_metadata.py +++ b/bats_ai/core/models/pulse_metadata.py @@ -8,4 +8,7 @@ class PulseMetadata(models.Model): index = models.IntegerField(null=False, blank=False) bounding_box = models.PolygonField(null=False, blank=False) contours = models.JSONField() - # TODO: Add in metadata from batbot + curve = models.LineStringField(null=True, blank=True) + char_freq = models.PointField(null=True, blank=True) + knee = models.PointField(null=True, blank=True) + heel = models.PointField(null=True, blank=True) diff --git a/bats_ai/core/tasks/nabat/tasks.py b/bats_ai/core/tasks/nabat/tasks.py index 255e9747..7646dac4 100644 --- a/bats_ai/core/tasks/nabat/tasks.py +++ b/bats_ai/core/tasks/nabat/tasks.py @@ -2,9 +2,10 @@ from pathlib import Path import tempfile +from django.contrib.gis.geos import LineString, Point, Polygon import requests -from bats_ai.core.models import Configuration, ProcessingTask, Species +from bats_ai.core.models import Configuration, ProcessingTask, PulseMetadata, Species from bats_ai.core.models.nabat import NABatRecording, NABatRecordingAnnotation from bats_ai.core.utils.batbot_metadata import generate_spectrogram_assets from bats_ai.utils.spectrogram_utils import ( @@ -57,6 +58,48 @@ def generate_spectrograms( compressed_obj = generate_nabat_compressed_spectrogram( nabat_recording, spectrogram, compressed ) + segment_index_map = {} + for segment in compressed['contours']['segments']: + pulse_metadata_obj, _ = PulseMetadata.objects.get_or_create( + recording=compressed_obj.recording, + index=segment['segment_index'], + defaults={ + 'contours': segment['contours'], + 'bounding_box': Polygon( + ( + (segment['start_ms'], segment['freq_max']), + (segment['stop_ms'], segment['freq_max']), + (segment['stop_ms'], segment['freq_min']), + (segment['start_ms'], segment['freq_min']), + (segment['start_ms'], segment['freq_max']), + ) + ), + }, + ) + segment_index_map[segment['segment_index']] = pulse_metadata_obj + for segment in compressed['segments']: + if segment['segment_index'] not in segment_index_map: + PulseMetadata.objects.get_or_create( + recording=compressed_obj.recording, + index=segment['segment_index'], + defaults={ + 'curve': LineString([Point(x[1], x[0]) for x in segment['curve_hz_ms']]), + 'char_freq': Point(segment['char_freq_ms'], segment['char_freq_hz']), + 'knee': Point(segment['knee_ms'], segment['knee_hz']), + 'heel': Point(segment['heel_ms'], segment['heel_hz']), + }, + ) + else: + pulse_metadata_obj = segment_index_map[segment['segment_index']] + pulse_metadata_obj.curve = LineString( + [Point(x[1], x[0]) for x in segment['curve_hz_ms']] + ) + pulse_metadata_obj.char_freq = Point( + segment['char_freq_ms'], segment['char_freq_hz'] + ) + pulse_metadata_obj.knee = Point(segment['knee_ms'], segment['knee_hz']) + pulse_metadata_obj.heel = Point(segment['heel_ms'], segment['heel_hz']) + pulse_metadata_obj.save() try: config = Configuration.objects.first() diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index e221571a..bea678a7 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -4,7 +4,7 @@ import tempfile from django.contrib.contenttypes.models import ContentType -from django.contrib.gis.geos import Polygon +from django.contrib.gis.geos import LineString, Point, Polygon from django.core.files import File from bats_ai.celery import app @@ -104,8 +104,9 @@ def recording_compute_spectrogram(recording_id: int): ) # Create SpectrogramContour objects for each segment - for segment in results['segments']['segments']: - PulseMetadata.objects.get_or_create( + segment_index_map = {} + for segment in compressed['contours']['segments']: + pulse_metadata_obj, _ = PulseMetadata.objects.get_or_create( recording=compressed_obj.recording, index=segment['segment_index'], defaults={ @@ -121,6 +122,30 @@ def recording_compute_spectrogram(recording_id: int): ), }, ) + segment_index_map[segment['segment_index']] = pulse_metadata_obj + for segment in compressed['segments']: + if segment['segment_index'] not in segment_index_map: + PulseMetadata.objects.get_or_create( + recording=compressed_obj.recording, + index=segment['segment_index'], + defaults={ + 'curve': LineString([Point(x[1], x[0]) for x in segment['curve_hz_ms']]), + 'char_freq': Point(segment['char_freq_ms'], segment['char_freq_hz']), + 'knee': Point(segment['knee_ms'], segment['knee_hz']), + 'heel': Point(segment['heel_ms'], segment['heel_hz']), + }, + ) + else: + pulse_metadata_obj = segment_index_map[segment['segment_index']] + pulse_metadata_obj.curve = LineString( + [Point(x[1], x[0]) for x in segment['curve_hz_ms']] + ) + pulse_metadata_obj.char_freq = Point( + segment['char_freq_ms'], segment['char_freq_hz'] + ) + pulse_metadata_obj.knee = Point(segment['knee_ms'], segment['knee_hz']) + pulse_metadata_obj.heel = Point(segment['heel_ms'], segment['heel_hz']) + pulse_metadata_obj.save() config = Configuration.objects.first() # TODO: Disabled until prediction is in batbot diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index 730a5083..d7032ba1 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -1,5 +1,6 @@ from contextlib import contextmanager import json +import logging import os from pathlib import Path from typing import Any, TypedDict @@ -9,6 +10,8 @@ from .contour_utils import process_spectrogram_assets_for_contours +logger = logging.getLogger(__name__) + class SpectrogramMetadata(BaseModel): """Metadata about the spectrogram.""" @@ -255,6 +258,17 @@ class SpectrogramContourSegment(TypedDict): stop_ms: float +class BatBotMetadataCurve(TypedDict): + segment_index: int + curve_hz_ms: list[float] + char_freq_ms: float + char_freq_hz: float + knee_ms: float + knee_hz: float + heel_ms: float + heel_hz: float + + class SpectrogramContours(TypedDict): segments: list[SpectrogramContourSegment] total_segments: int @@ -266,7 +280,7 @@ class SpectrogramAssets(TypedDict): freq_max: int normal: SpectrogramAssetResult compressed: SpectrogramCompressedAssetResult - segments: SpectrogramContours | None + contours: SpectrogramContours | None @contextmanager @@ -279,6 +293,25 @@ def working_directory(path): os.chdir(previous) +def convert_to_segment_data( + metadata: BatbotMetadata, +) -> list[BatBotMetadataCurve]: + segment_data: list[BatBotMetadataCurve] = [] + for index, segment in enumerate(metadata.segments): + segment_data_item: BatBotMetadataCurve = { + 'segment_index': index, + 'curve_hz_ms': segment.curve_hz_ms, + 'char_freq_ms': segment.fc_ms, + 'char_freq_hz': segment.fc_hz, + 'knee_ms': segment.hi_fc_knee_ms, + 'knee_hz': segment.hi_fc_knee_hz, + 'heel_ms': segment.lo_fc_heel_ms, + 'heel_hz': segment.lo_fc_heel_hz, + } + segment_data.append(segment_data_item) + return segment_data + + def generate_spectrogram_assets(recording_path: str, output_folder: str): batbot.pipeline(recording_path, output_folder=output_folder) # There should be a .metadata.json file in the output_base directory by replacing extentions @@ -294,6 +327,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): metadata.frequencies.max_hz compressed_metadata = convert_to_compressed_spectrogram_data(metadata) + segment_curve_data = convert_to_segment_data(metadata) result: SpectrogramAssets = { 'duration': metadata.duration_ms, 'freq_min': metadata.frequencies.min_hz, @@ -311,10 +345,11 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): 'widths': compressed_metadata.widths, 'starts': compressed_metadata.starts, 'stops': compressed_metadata.stops, + 'segments': segment_curve_data, }, } - segments_data = process_spectrogram_assets_for_contours(result) - result['segments'] = segments_data + contour_segments_data = process_spectrogram_assets_for_contours(result) + result['compressed']['contours'] = contour_segments_data return result diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 3763f681..55df00c7 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -130,7 +130,7 @@ class UpdateAnnotationsSchema(Schema): id: int | None -class PulseMetadataSchema(Schema): +class PulseContourSchema(Schema): id: int | None index: int bounding_box: Any @@ -146,6 +146,36 @@ def from_orm(cls, obj: PulseMetadata): ) +class PulseMetadataSchema(Schema): + id: int | None + index: int + curve: list[list[float]] | None = None # list of [time, frequency] + char_freq: list[float] | None = None # point [time, frequency] + knee: list[float] | None = None # point [time, frequency] + heel: list[float] | None = None # point [time, frequency] + + @classmethod + def from_orm(cls, obj: PulseMetadata): + def point_to_list(pt): + if pt is None: + return None + return [pt.x, pt.y] + + def linestring_to_list(ls): + if ls is None: + return None + return [[c[0], c[1]] for c in ls.coords] + + return cls( + id=obj.id, + index=obj.index, + curve=linestring_to_list(obj.curve), + char_freq=point_to_list(obj.char_freq), + knee=point_to_list(obj.knee), + heel=point_to_list(obj.heel), + ) + + @router.post('/') def create_recording( request: HttpRequest, @@ -560,6 +590,25 @@ def get_annotations(request: HttpRequest, id: int): return {'error': 'Recording not found'} +@router.get('/{id}/pulse_contours') +def get_pulse_contours(request: HttpRequest, id: int): + try: + recording = Recording.objects.get(pk=id) + if recording.owner == request.user or recording.public: + computed_pulse_annotation_qs = PulseMetadata.objects.filter( + recording=recording + ).order_by('index') + return [ + PulseContourSchema.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}/pulse_data') def get_pulse_data(request: HttpRequest, id: int): try: diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 271cd31e..f23dd566 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -574,14 +574,28 @@ export interface Contour { index: number; } -export interface ComputedPulseAnnotation { +export interface ComputedPulseContour { id: number; index: number; contours: Contour[]; } -async function getComputedPulseAnnotations(recordingId: number) { - const result = await axiosInstance.get(`/recording/${recordingId}/pulse_data`); +async function getComputedPulseContour(recordingId: number) { + const result = await axiosInstance.get(`/recording/${recordingId}/pulse_contours`); + return result.data; +} + +export interface PulseMetadata { + id: number; + index: number; + curve: number[][]; // list of [time, frequency] + char_freq: number[]; // point [time, frequency] + knee: number[]; // point [time, frequency] + heel: number[]; // point [time, frequency] +} + +async function getPulseMetadata(recordingId: number) { + const result = await axiosInstance.get(`/recording/${recordingId}/pulse_data`); return result.data; } @@ -622,7 +636,8 @@ export { getFileAnnotationDetails, getExportStatus, getRecordingTags, - getComputedPulseAnnotations, + getComputedPulseContour, + getPulseMetadata, getCurrentUser, getVettingDetailsForUser, createOrUpdateVettingDetailsForUser, diff --git a/client/src/components/PulseMetadataButton.vue b/client/src/components/PulseMetadataButton.vue new file mode 100644 index 00000000..dcc08913 --- /dev/null +++ b/client/src/components/PulseMetadataButton.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 7a5f7162..f7ea553d 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -21,6 +21,7 @@ import MeasureToolLayer from "./layers/measureToolLayer"; import BoundingBoxLayer from "./layers/boundingBoxLayer"; import AxesLayer from "./layers/axesLayer"; import ContourLayer from "./layers/contourLayer"; +import PulseMetadataLayer from "./layers/pulseMetadataLayer"; import { cloneDeep } from "lodash"; import useState from "@use/useState"; @@ -81,8 +82,18 @@ export default defineComponent({ contoursEnabled, contourOpacity, loadContours, - computedPulseAnnotations, + computedPulseContours, transparencyThreshold, + viewPulseMetadataLayer, + pulseMetadataList, + loadPulseMetadata, + clearPulseMetadata, + pulseMetadataLineColor, + pulseMetadataLineSize, + pulseMetadataHeelColor, + pulseMetadataCharFreqColor, + pulseMetadataKneeColor, + pulseMetadataPointSize, } = useState(); const selectedAnnotationId: Ref = ref(null); const hoveredAnnotationId: Ref = ref(null); @@ -103,6 +114,7 @@ export default defineComponent({ let measureToolLayer: MeasureToolLayer; let boundingBoxLayer: BoundingBoxLayer; let contourLayer: ContourLayer; + let pulseMetadataLayer: PulseMetadataLayer; const displayError = ref(false); const errorMsg = ref(""); @@ -457,7 +469,10 @@ export default defineComponent({ triggerUpdate(); } ); - watch(() => props.recordingId, () => computedPulseAnnotations.value = []); + watch(() => props.recordingId, () => { + computedPulseContours.value = []; + clearPulseMetadata(); + }); watch(contoursEnabled, async () => { if (props.thumbnail) { return; @@ -466,7 +481,7 @@ export default defineComponent({ console.error('Could not load contours. Could not determine recording ID'); return; } - if (computedPulseAnnotations.value.length === 0) { + if (computedPulseContours.value.length === 0) { await loadContours(new Number(props.recordingId) as number); } if (!contourLayer) { @@ -474,7 +489,7 @@ export default defineComponent({ props.geoViewerRef, event, props.spectroInfo, - computedPulseAnnotations.value, + computedPulseContours.value, colorScheme.value.scheme, ); } @@ -492,6 +507,66 @@ export default defineComponent({ contourLayer.updateContourStyle(); } }); + watch(viewPulseMetadataLayer, async () => { + if (props.thumbnail) return; + if (!props.recordingId || !props.spectroInfo?.compressedWidth) return; + if (viewPulseMetadataLayer.value) { + if (pulseMetadataList.value.length === 0) { + await loadPulseMetadata(Number(props.recordingId)); + } + if (!pulseMetadataLayer) { + pulseMetadataLayer = new PulseMetadataLayer( + props.geoViewerRef, + props.spectroInfo, + pulseMetadataList.value, + ); + } + pulseMetadataLayer.spectroInfo = props.spectroInfo; + pulseMetadataLayer.setStyle({ + lineColor: pulseMetadataLineColor.value, + lineWidth: pulseMetadataLineSize.value, + heelColor: pulseMetadataHeelColor.value, + charFreqColor: pulseMetadataCharFreqColor.value, + kneeColor: pulseMetadataKneeColor.value, + pointRadius: pulseMetadataPointSize.value, + }); + pulseMetadataLayer.setPulseMetadataList(pulseMetadataList.value); + pulseMetadataLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); + pulseMetadataLayer.redraw(); + } else if (pulseMetadataLayer) { + pulseMetadataLayer.disable(); + } + }); + watch(pulseMetadataList, () => { + if (pulseMetadataLayer && viewPulseMetadataLayer.value && props.spectroInfo) { + pulseMetadataLayer.setPulseMetadataList(pulseMetadataList.value); + pulseMetadataLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); + pulseMetadataLayer.redraw(); + } + }); + watch( + [ + pulseMetadataLineColor, + pulseMetadataLineSize, + pulseMetadataHeelColor, + pulseMetadataCharFreqColor, + pulseMetadataKneeColor, + pulseMetadataPointSize, + ], + () => { + if (pulseMetadataLayer && viewPulseMetadataLayer.value) { + pulseMetadataLayer.setStyle({ + lineColor: pulseMetadataLineColor.value, + lineWidth: pulseMetadataLineSize.value, + heelColor: pulseMetadataHeelColor.value, + charFreqColor: pulseMetadataCharFreqColor.value, + kneeColor: pulseMetadataKneeColor.value, + pointRadius: pulseMetadataPointSize.value, + }); + pulseMetadataLayer.redraw(); + } + }, + ); onUnmounted(() => { if (editAnnotationLayer) { editAnnotationLayer.destroy(); @@ -517,6 +592,9 @@ export default defineComponent({ if (speciesSequenceLayer) { speciesSequenceLayer.destroy(); } + if (pulseMetadataLayer) { + pulseMetadataLayer.destroy(); + } }); function setAxes() { @@ -700,6 +778,9 @@ export default defineComponent({ contourLayer.updateContourStyle(); } } + if (pulseMetadataLayer && viewPulseMetadataLayer.value) { + pulseMetadataLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); + } editAnnotationLayer?.setScaledDimensions(props.scaledWidth, props.scaledHeight); if (editing.value && editingAnnotation.value) { setTimeout(() => { diff --git a/client/src/components/geoJS/layers/contourLayer.ts b/client/src/components/geoJS/layers/contourLayer.ts index 61e7dc9a..9bbe0cea 100644 --- a/client/src/components/geoJS/layers/contourLayer.ts +++ b/client/src/components/geoJS/layers/contourLayer.ts @@ -1,6 +1,6 @@ import { SpectroInfo } from '../geoJSUtils'; import { - ComputedPulseAnnotation, + ComputedPulseContour, Contour, } from '@api/api'; @@ -35,7 +35,7 @@ export default class ContourLayer { scaledWidth: number; - computedPulseAnnotations: ComputedPulseAnnotation[]; + computedPulseContours: ComputedPulseContour[]; constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,7 +43,7 @@ export default class ContourLayer { // eslint-disable-next-line @typescript-eslint/no-explicit-any event: (name: string, data: any) => void, spectroInfo: SpectroInfo, - computedPulseAnnotations: ComputedPulseAnnotation[], + computedPulseContours: ComputedPulseContour[], colorScheme: (t: number) => string, ) { this.geoViewerRef = geoViewerRef; @@ -52,7 +52,7 @@ export default class ContourLayer { this.scaledHeight = this.spectroInfo.height; this.scaledWidth = this.spectroInfo.width; this.colorScheme = colorScheme; - this.computedPulseAnnotations = computedPulseAnnotations; + this.computedPulseContours = computedPulseContours; this.features = []; this.maxLevel = 0; this.contourOpacity = 1.0; @@ -126,12 +126,12 @@ export default class ContourLayer { } drawContours() { - this.computedPulseAnnotations.forEach((annotation: ComputedPulseAnnotation) => annotation.contours.forEach((contour: Contour) => { + this.computedPulseContours.forEach((contour: ComputedPulseContour) => contour.contours.forEach((contour: Contour) => { if (contour.level > this.maxLevel) { this.maxLevel = contour.level; } })); - this.computedPulseAnnotations.forEach((pulseAnnotation: ComputedPulseAnnotation) => this.drawPolygonsForPulse(pulseAnnotation.contours)); + this.computedPulseContours.forEach((pulseContour: ComputedPulseContour) => this.drawPolygonsForPulse(pulseContour.contours)); } getTransformedContourPoint(point: number[], level: number, index: number): ContourPoint { diff --git a/client/src/components/geoJS/layers/pulseMetadataLayer.ts b/client/src/components/geoJS/layers/pulseMetadataLayer.ts new file mode 100644 index 00000000..28c81211 --- /dev/null +++ b/client/src/components/geoJS/layers/pulseMetadataLayer.ts @@ -0,0 +1,196 @@ +import { SpectroInfo } from '../geoJSUtils'; +import { PulseMetadata } from '@api/api'; +import { LayerStyle, LineData } from './types'; + +/** Point data for char_freq, knee, heel with pixel coords and label. */ +interface PulsePointData { + x: number; + y: number; + label: string; +} + +export interface PulseMetadataStyle { + lineColor: string; + lineWidth: number; + heelColor: string; + charFreqColor: string; + kneeColor: string; + pointRadius: number; +} + +const defaultPulseMetadataStyle: PulseMetadataStyle = { + lineColor: '#00FFFF', + lineWidth: 2, + heelColor: '#FF0088', + charFreqColor: '#00FF00', + kneeColor: '#FF8800', + pointRadius: 5, +}; + +export default class PulseMetadataLayer { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any; + spectroInfo: SpectroInfo; + scaledWidth: number; + scaledHeight: number; + pulseMetadataList: PulseMetadata[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + featureLayer: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lineLayer: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pointLayer: any; + lineData: LineData[] = []; + pointData: PulsePointData[] = []; + style: PulseMetadataStyle = { ...defaultPulseMetadataStyle }; + + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any, + spectroInfo: SpectroInfo, + pulseMetadataList: PulseMetadata[], + ) { + this.geoViewerRef = geoViewerRef; + this.spectroInfo = spectroInfo; + this.scaledWidth = spectroInfo.width; + this.scaledHeight = spectroInfo.height; + this.pulseMetadataList = pulseMetadataList; + this.featureLayer = this.geoViewerRef.createLayer('feature', { + features: ['line', 'point'], + }); + this.lineLayer = this.featureLayer.createFeature('line'); + this.pointLayer = this.featureLayer.createFeature('point'); + } + + setScaledDimensions(scaledWidth: number, scaledHeight: number) { + this.scaledWidth = scaledWidth; + this.scaledHeight = scaledHeight; + this.formatData(); + this.redraw(); + } + + setPulseMetadataList(pulseMetadataList: PulseMetadata[]) { + this.pulseMetadataList = pulseMetadataList; + this.formatData(); + this.redraw(); + } + + setStyle(style: Partial) { + this.style = { ...defaultPulseMetadataStyle, ...this.style, ...style }; + } + + getCompressedPosition(time: number, freq: number, index: number): { x: number; y: number } { + if ( + !this.spectroInfo.start_times + || !this.spectroInfo.end_times + || !this.spectroInfo.widths + || !this.spectroInfo.compressedWidth + ) { + return { x: 0, y: 0 }; + } + const scaleFactor = this.scaledWidth / this.spectroInfo.compressedWidth; + const startTime = this.spectroInfo.start_times[index]; + const endTime = this.spectroInfo.end_times[index]; + const targetTime = Math.min(time, endTime); + const width = this.spectroInfo.widths[index]; + let segmentOffset = 0; + for (let i = 0; i < index; i++) { + segmentOffset += this.spectroInfo.widths[i] * scaleFactor; + } + const pixelsPerMs = width / (endTime - startTime); + const x = segmentOffset + (targetTime - startTime) * pixelsPerMs * scaleFactor; + const y = this._getYValueFromFrequency(freq); + return { x, y }; + } + + _getYValueFromFrequency(freq: number): 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; + } + + formatData() { + this.lineData = []; + this.pointData = []; + if (!this.spectroInfo.compressedWidth || !this.pulseMetadataList.length) { + return; + } + this.pulseMetadataList.forEach((pulse: PulseMetadata) => { + const index = pulse.index; + if (pulse.curve && pulse.curve.length >= 2) { + const coords: [number, number][] = pulse.curve.map((pt) => { + const pos = this.getCompressedPosition(pt[0], pt[1], index); + return [pos.x, pos.y]; + }); + this.lineData.push({ + line: { type: 'LineString', coordinates: coords }, + }); + } + if (pulse.char_freq && pulse.char_freq.length >= 2) { + const pos = this.getCompressedPosition(pulse.char_freq[0], pulse.char_freq[1], index); + this.pointData.push({ x: pos.x, y: pos.y, label: 'char_freq' }); + } + if (pulse.knee && pulse.knee.length >= 2) { + const pos = this.getCompressedPosition(pulse.knee[0], pulse.knee[1], index); + this.pointData.push({ x: pos.x, y: pos.y, label: 'knee' }); + } + if (pulse.heel && pulse.heel.length >= 2) { + const pos = this.getCompressedPosition(pulse.heel[0], pulse.heel[1], index); + this.pointData.push({ x: pos.x, y: pos.y, label: 'heel' }); + } + }); + } + + createLineStyle(): LayerStyle { + const { lineColor, lineWidth } = this.style; + return { + strokeColor: lineColor, + strokeWidth: lineWidth, + stroke: true, + fill: false, + }; + } + + createPointStyle(): LayerStyle { + const { heelColor, charFreqColor, kneeColor, pointRadius } = this.style; + return { + fillColor: (d: PulsePointData) => { + if (d.label === 'char_freq') return charFreqColor; + if (d.label === 'knee') return kneeColor; + if (d.label === 'heel') return heelColor; + return '#FFFFFF'; + }, + fill: true, + stroke: true, + strokeColor: '#FFFFFF', + strokeWidth: 1, + radius: pointRadius, + }; + } + + redraw() { + this.formatData(); + this.lineLayer + .data(this.lineData) + .line((d: LineData) => d.line.coordinates) + .style(this.createLineStyle()) + .draw(); + this.pointLayer + .data(this.pointData) + .position((d: PulsePointData) => ({ x: d.x, y: d.y })) + .style(this.createPointStyle()) + .draw(); + } + + disable() { + this.lineLayer.data([]).draw(); + this.pointLayer.data([]).draw(); + } + + destroy() { + if (this.featureLayer) { + this.geoViewerRef.deleteLayer(this.featureLayer); + } + } +} diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index 24f99ba9..20bceb88 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -12,8 +12,10 @@ import { SpectrogramSequenceAnnotation, RecordingTag, FileAnnotation, - getComputedPulseAnnotations, - ComputedPulseAnnotation, + getComputedPulseContour, + ComputedPulseContour, + getPulseMetadata, + PulseMetadata, getVettingDetailsForUser, } from "../api/api"; import { @@ -88,7 +90,7 @@ const toggleFixedAxes = () => { fixedAxes.value = !fixedAxes.value; }; -const computedPulseAnnotations: Ref = ref([]); +const computedPulseContours: Ref = ref([]); // Show contours is always false; not persisted or loaded from localStorage. const contoursEnabled = ref(false); const imageOpacity = ref(1.0); @@ -104,13 +106,39 @@ const toggleContoursEnabled = () => { }; async function loadContours(recordingId: number) { contoursLoading.value = true; - computedPulseAnnotations.value = await getComputedPulseAnnotations(recordingId); + computedPulseContours.value = await getComputedPulseContour(recordingId); contoursLoading.value = false; } function clearContours() { - computedPulseAnnotations.value = []; + computedPulseContours.value = []; } +const pulseMetadataList: Ref = ref([]); +const pulseMetadataLoading = ref(false); +const viewPulseMetadataLayer = ref(false); +async function loadPulseMetadata(recordingId: number) { + pulseMetadataLoading.value = true; + try { + pulseMetadataList.value = await getPulseMetadata(recordingId); + } finally { + pulseMetadataLoading.value = false; + } +} +function clearPulseMetadata() { + pulseMetadataList.value = []; +} +const toggleViewPulseMetadataLayer = () => { + viewPulseMetadataLayer.value = !viewPulseMetadataLayer.value; +}; + +// Pulse metadata display style (curve line + heel, char_freq, knee colors and sizes) +const pulseMetadataLineColor = ref("#00FFFF"); +const pulseMetadataLineSize = ref(2); +const pulseMetadataHeelColor = ref("#FF0088"); +const pulseMetadataCharFreqColor = ref("#00FF00"); +const pulseMetadataKneeColor = ref("#FF8800"); +const pulseMetadataPointSize = ref(5); + const reviewerMaterials = ref(''); const transparencyThreshold = ref(0); // 0-100 percentage @@ -382,7 +410,19 @@ export default function useState() { toggleContoursEnabled, loadContours, clearContours, - computedPulseAnnotations, + computedPulseContours, + pulseMetadataList, + pulseMetadataLoading, + loadPulseMetadata, + clearPulseMetadata, + viewPulseMetadataLayer, + toggleViewPulseMetadataLayer, + pulseMetadataLineColor, + pulseMetadataLineSize, + pulseMetadataHeelColor, + pulseMetadataCharFreqColor, + pulseMetadataKneeColor, + pulseMetadataPointSize, showSubmittedRecordings, submittedMyRecordings, submittedSharedRecordings, diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index d757c24e..360aeca7 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -30,6 +30,7 @@ import TransparencyFilterControl from "@/components/TransparencyFilterControl.vu import RecordingInfoDialog from "@components/RecordingInfoDialog.vue"; import ReferenceMaterialsDialog from "@/components/ReferenceMaterialsDialog.vue"; import SpectrogramImageContentMenu from "@/components/SpectrogramImageContentMenu.vue"; +import PulseMetadataButton from "@/components/PulseMetadataButton.vue"; import useState from "@use/useState"; export default defineComponent({ name: "Spectrogram", @@ -44,6 +45,7 @@ export default defineComponent({ ReferenceMaterialsDialog, SpectrogramImageContentMenu, TransparencyFilterControl, + PulseMetadataButton, }, props: { id: { @@ -80,6 +82,8 @@ export default defineComponent({ toggleFixedAxes, contoursLoading, clearContours, + clearPulseMetadata, + viewPulseMetadataLayer, nextUnsubmittedRecordingId, previousUnsubmittedRecordingId, currentRecordingId, @@ -134,6 +138,8 @@ export default defineComponent({ const spectrogramData: Ref = ref(null); const loadData = async () => { viewMaskOverlay.value = false; + viewPulseMetadataLayer.value = false; + clearPulseMetadata(); loading.value = true; currentRecordingId.value = parseInt(props.id); loadedImage.value = false; @@ -532,7 +538,7 @@ export default defineComponent({ @@ -546,7 +552,7 @@ export default defineComponent({ @@ -560,7 +566,7 @@ export default defineComponent({ @@ -577,7 +583,7 @@ export default defineComponent({ @@ -586,16 +592,22 @@ export default defineComponent({ Highlight Compressed Areas -
+
+ +
+
-
+
-
+
From 1ad4670994c8c3c5d01cb1f5650956beacdb4ad6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 2 Feb 2026 12:19:25 -0500 Subject: [PATCH 2/5] reconfigure pulseMetadata, add labels --- client/src/components/PulseMetadataButton.vue | 153 +++++++++++------- client/src/components/geoJS/LayerManager.vue | 74 +++++---- .../geoJS/layers/pulseMetadataLayer.ts | 143 +++++++++++++++- client/src/components/geoJS/layers/types.ts | 2 + client/src/use/usePulseMetadata.ts | 118 ++++++++++++++ client/src/use/useState.ts | 42 +---- client/src/views/Spectrogram.vue | 5 + 7 files changed, 408 insertions(+), 129 deletions(-) create mode 100644 client/src/use/usePulseMetadata.ts diff --git a/client/src/components/PulseMetadataButton.vue b/client/src/components/PulseMetadataButton.vue index dcc08913..0d4b69c1 100644 --- a/client/src/components/PulseMetadataButton.vue +++ b/client/src/components/PulseMetadataButton.vue @@ -1,5 +1,5 @@