Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion bats_ai/core/admin/pulse_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,14 @@

@admin.register(PulseMetadata)
class PulseMetadataAdmin(admin.ModelAdmin):
list_display = ("recording", "index", "bounding_box", "curve", "char_freq", "knee", "heel")
list_display = (
"recording",
"index",
"bounding_box",
"curve",
"char_freq",
"knee",
"heel",
"slopes",
)
list_select_related = True
18 changes: 18 additions & 0 deletions bats_ai/core/migrations/0032_pulsemetadata_slopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-02-26 17:07
from __future__ import annotations

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0031_alter_species_category"),
]

operations = [
migrations.AddField(
model_name="pulsemetadata",
name="slopes",
field=models.JSONField(blank=True, null=True),
),
]
1 change: 1 addition & 0 deletions bats_ai/core/models/pulse_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ class PulseMetadata(models.Model):
char_freq = models.PointField(null=True, blank=True)
knee = models.PointField(null=True, blank=True)
heel = models.PointField(null=True, blank=True)
slopes = models.JSONField(null=True, blank=True)
4 changes: 4 additions & 0 deletions bats_ai/core/tasks/nabat/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def generate_spectrograms(
"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"]),
"slopes": segment.get("slopes"),
},
)
else:
Expand All @@ -103,6 +104,9 @@ def generate_spectrograms(
)
pulse_metadata_obj.knee = Point(segment["knee_ms"], segment["knee_hz"])
pulse_metadata_obj.heel = Point(segment["heel_ms"], segment["heel_hz"])
slopes = segment.get("slopes")
if slopes:
pulse_metadata_obj.slopes = slopes
pulse_metadata_obj.save()

processing_task.status = ProcessingTask.Status.COMPLETE
Expand Down
4 changes: 4 additions & 0 deletions bats_ai/core/tasks/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def recording_compute_spectrogram(self, recording_id: int):
"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"]),
"slopes": segment.get("slopes"),
},
)
else:
Expand All @@ -166,6 +167,9 @@ def recording_compute_spectrogram(self, recording_id: int):
)
pulse_metadata_obj.knee = Point(segment["knee_ms"], segment["knee_hz"])
pulse_metadata_obj.heel = Point(segment["heel_ms"], segment["heel_hz"])
slopes = segment.get("slopes")
if slopes:
pulse_metadata_obj.slopes = slopes
pulse_metadata_obj.save()

if processing_task:
Expand Down
43 changes: 42 additions & 1 deletion bats_ai/core/utils/batbot_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import logging
import os
from pathlib import Path
from typing import Any, TypedDict
from typing import Any, NotRequired, TypedDict

try:
import batbot
Expand Down Expand Up @@ -266,6 +266,39 @@ class SpectrogramContourSegment(TypedDict):
stop_ms: float


class BatBotSlopes(TypedDict, total=False):
"""Slope values from batbot (kHz/ms). All keys optional."""

slope_at_hi_fc_knee_khz_per_ms: float | None
slope_at_fc_khz_per_ms: float | None
slope_at_low_fc_heel_khz_per_ms: float | None
slope_at_peak_khz_per_ms: float | None
slope_avg_khz_per_ms: float | None
slope_hi_avg_khz_per_ms: float | None
slope_mid_avg_khz_per_ms: float | None
slope_lo_avg_khz_per_ms: float | None
slope_box_khz_per_ms: float | None
slope_hi_box_khz_per_ms: float | None
slope_mid_box_khz_per_ms: float | None
slope_lo_box_khz_per_ms: float | None


_SEGMENT_SLOPE_KEYS: tuple[str, ...] = (
"slope_at_hi_fc_knee_khz_per_ms",
"slope_at_fc_khz_per_ms",
"slope_at_low_fc_heel_khz_per_ms",
"slope_at_peak_khz_per_ms",
"slope_avg_khz_per_ms",
"slope_hi_avg_khz_per_ms",
"slope_mid_avg_khz_per_ms",
"slope_lo_avg_khz_per_ms",
"slope_box_khz_per_ms",
"slope_hi_box_khz_per_ms",
"slope_mid_box_khz_per_ms",
"slope_lo_box_khz_per_ms",
)


class BatBotMetadataCurve(TypedDict):
segment_index: int
curve_hz_ms: list[float]
Expand All @@ -275,6 +308,7 @@ class BatBotMetadataCurve(TypedDict):
knee_hz: float
heel_ms: float
heel_hz: float
slopes: NotRequired[BatBotSlopes]


class SpectrogramContours(TypedDict):
Expand Down Expand Up @@ -306,6 +340,12 @@ def convert_to_segment_data(
) -> list[BatBotMetadataCurve]:
segment_data: list[BatBotMetadataCurve] = []
for index, segment in enumerate(metadata.segments):
slopes: BatBotSlopes = {}
for key in _SEGMENT_SLOPE_KEYS:
value = getattr(segment, key, None)
if value is not None:
slopes[key] = value

segment_data_item: BatBotMetadataCurve = {
"segment_index": index,
"curve_hz_ms": segment.curve_hz_ms,
Expand All @@ -315,6 +355,7 @@ def convert_to_segment_data(
"knee_hz": segment.hi_fc_knee_hz,
"heel_ms": segment.lo_fc_heel_ms,
"heel_hz": segment.lo_fc_heel_hz,
"slopes": slopes,
}
segment_data.append(segment_data_item)
return segment_data
Expand Down
17 changes: 17 additions & 0 deletions bats_ai/core/views/recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,29 @@ def from_orm(cls, obj: PulseMetadata):
)


class PulseMetadataSlopesSchema(Schema):
slope_at_hi_fc_knee_khz_per_ms: float | None
slope_at_fc_khz_per_ms: float | None
slope_at_low_fc_heel_khz_per_ms: float | None
slope_at_peak_khz_per_ms: float | None
slope_avg_khz_per_ms: float | None
slope_hi_avg_khz_per_ms: float | None
slope_mid_avg_khz_per_ms: float | None
slope_lo_avg_khz_per_ms: float | None
slope_box_khz_per_ms: float | None
slope_hi_box_khz_per_ms: float | None
slope_mid_box_khz_per_ms: float | None
slope_lo_box_khz_per_ms: float | None


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]
slopes: PulseMetadataSlopesSchema | None = None # batbot slope values (kHz/ms)

@classmethod
def from_orm(cls, obj: PulseMetadata):
Expand All @@ -247,6 +263,7 @@ def linestring_to_list(ls):
char_freq=point_to_list(obj.char_freq),
knee=point_to_list(obj.knee),
heel=point_to_list(obj.heel),
slopes=obj.slopes,
)


Expand Down
17 changes: 17 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,13 +658,30 @@ async function getComputedPulseContour(recordingId: number) {
return result.data;
}

/** Batbot slope values (kHz/ms). Keys match backend BatBotSlopes. */
export interface PulseMetadataSlopes {
slope_at_hi_fc_knee_khz_per_ms?: number;
slope_at_fc_khz_per_ms?: number;
slope_at_low_fc_heel_khz_per_ms?: number;
slope_at_peak_khz_per_ms?: number;
slope_avg_khz_per_ms?: number;
slope_hi_avg_khz_per_ms?: number;
slope_mid_avg_khz_per_ms?: number;
slope_lo_avg_khz_per_ms?: number;
slope_box_khz_per_ms?: number;
slope_hi_box_khz_per_ms?: number;
slope_mid_box_khz_per_ms?: number;
slope_lo_box_khz_per_ms?: number;
}

export interface PulseMetadata {
id: number;
index: number;
curve: number[][] | null; // list of [time, frequency]
char_freq: number[] | null; // point [time, frequency]
knee: number[] | null; // point [time, frequency]
heel: number[] | null; // point [time, frequency]
slopes?: PulseMetadataSlopes | null;
}

async function getPulseMetadata(recordingId: number) {
Expand Down
49 changes: 36 additions & 13 deletions client/src/components/PulseMetadataTooltip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,36 +69,59 @@ export default defineComponent({
v-if="data.kneeKhz != null"
class="d-flex align-center"
>
<span v-if="data.kneeColor"
class="color-swatch"
:style="{ backgroundColor: data.kneeColor }"
/>
<span
v-if="data.kneeColor"
class="color-swatch"
:style="{ backgroundColor: data.kneeColor }"
/>
<span class="text-caption text-medium-emphasis mr-2">Knee</span>
<span>{{ data.kneeKhz.toFixed(1) }} kHz</span>
</div>
<div v-if="data.slopeAtHiFcKneeKhzPerMs != null">
<span
class="text-caption ml-4 text-medium-emphasis"
>
({{ data.slopeAtHiFcKneeKhzPerMs.toFixed(2) }} kHz/ms)
</span>
Comment on lines +81 to +85
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we might want proper labels for the slopes. If the slope is average from x1 to x2, it could be like "Slope from Fmax to Knee". If its at a point, it could be "Slope at Knee"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to wait until they see it and give me feedback, this is client side and can be changed easily.

</div>
<div
v-if="data.fcKhz != null"
class="d-flex align-center"
>
<span v-if="data.charFreqColor"
class="color-swatch"
:style="{ backgroundColor: data.charFreqColor }"
/>
<span
v-if="data.charFreqColor"
class="color-swatch"
:style="{ backgroundColor: data.charFreqColor }"
/>
<span class="text-caption text-medium-emphasis mr-2">Fc</span>
<span>{{ data.fcKhz.toFixed(1) }} kHz</span>
</div>
<div v-if="data.slopeAtFcKhzPerMs != null">
<span
class="text-caption ml-4 text-medium-emphasis"
>
({{ data.slopeAtFcKhzPerMs.toFixed(2) }} kHz/ms)
</span>
</div>
<div
v-if="data.heelKhz != null"
class="d-flex align-center"
>
<span v-if="data.heelColor"
class="color-swatch"
:style="{ backgroundColor: data.heelColor }"
/>
<span
v-if="data.heelColor"
class="color-swatch"
:style="{ backgroundColor: data.heelColor }"
/>
<span class="text-caption text-medium-emphasis mr-2">Heel</span>
<span>{{ data.heelKhz.toFixed(1) }} kHz</span>
</div>

<div v-if="data.slopeAtLowFcHeelKhzPerMs != null">
<span
class="text-caption ml-4 text-medium-emphasis"
>
({{ data.slopeAtLowFcHeelKhzPerMs.toFixed(2) }} kHz/ms)
</span>
</div>
<div class="d-flex align-center">
<span class="text-caption text-medium-emphasis mr-2">Duration</span>
<span>{{ data.durationMs.toFixed(1) }} ms</span>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/SpectrogramViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export default defineComponent({
<div
id="spectro"
ref="containerRef"
class="playback-container"
class="playback-container playback-container--with-tooltip"
:style="{ cursor: cursor }"
@mousemove="cursorHandler.handleMouseMove"
@mouseleave="cursorHandler.handleMouseLeave"
Expand Down
10 changes: 10 additions & 0 deletions client/src/components/geoJS/layers/pulseMetadataLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export interface PulseMetadataTooltipData {
charFreqColor: string | null;
heelKhz: number | null;
kneeKhz: number | null;
/** Slope at hi fc:knee (kHz/ms). */
slopeAtHiFcKneeKhzPerMs: number | null;
/** Slope at fc (kHz/ms). */
slopeAtFcKhzPerMs: number | null;
/** Slope at low fc:heel (kHz/ms). */
slopeAtLowFcHeelKhzPerMs: number | null;
bbox: { top: number; left: number; width: number; height: number };
}

Expand Down Expand Up @@ -273,6 +279,7 @@ export default class PulseMetadataLayer extends BaseTextLayer<TextData> {
if (pulse.char_freq && pulse.char_freq.length >= 2) {
fcKhz = pulse.char_freq[1] / 1000;
}
const slopes = pulse.slopes ?? undefined;
const { heelColor, charFreqColor, kneeColor } = this.style;
return {
durationMs,
Expand All @@ -284,6 +291,9 @@ export default class PulseMetadataLayer extends BaseTextLayer<TextData> {
heelKhz: pulse.heel ? pulse.heel[1] / 1000 : null,
kneeKhz: pulse.knee ? pulse.knee[1] / 1000 : null,
charFreqColor: pulse.char_freq ? charFreqColor : null,
slopeAtHiFcKneeKhzPerMs: slopes?.slope_at_hi_fc_knee_khz_per_ms ?? null,
slopeAtFcKhzPerMs: slopes?.slope_at_fc_khz_per_ms ?? null,
slopeAtLowFcHeelKhzPerMs: slopes?.slope_at_low_fc_heel_khz_per_ms ?? null,
bbox: { top: 0, left, width, height },
};
}
Expand Down
Loading