deepvoice_detection / helpers.py
moshedd's picture
Per-model runtime estimate; drop count/duration caps; 15-min compute guard
6d7ec02 verified
Raw
History Blame Contribute Delete
8 kB
"""Helpers for the Deep Voice multi-model bioacoustic Gradio app.
Wraps soundbay's `inference_to_file` so we can run inference programmatically
(without a Hydra CLI shell-out), and bundles file-validation / Raven-merge
utilities used by the UI layer in `app.py`.
"""
from __future__ import annotations
import os
import zipfile
from functools import lru_cache
from pathlib import Path
from typing import Optional
import pandas as pd
import soundfile as sf
import torch
from hydra import compose, initialize
from hydra.core.global_hydra import GlobalHydra
from huggingface_hub import hf_hub_download
from soundbay.inference import inference_to_file
from soundbay.utils.checkpoint_utils import merge_with_checkpoint
HF_CKPT_REPO = "deepvoice1/bioacoustic-checkpoints"
MAX_TOTAL_BYTES = 500 * 1024 * 1024 # 500 MB — only hard cap; runtime estimate guards the rest
# Raven TSV column order, used when we have to emit an empty merged file.
_RAVEN_COLS = [
"Selection", "View", "Channel", "Begin Time (s)", "End Time (s)",
"Low Freq (Hz)", "High Freq (Hz)", "Annotation", "Class Name",
"Probability", "Begin File",
]
@lru_cache(maxsize=16)
def download_checkpoint(filename: str) -> str:
"""Resolve a checkpoint path.
If `DV_LOCAL_CKPT_DIR` is set, look it up locally (for development).
Otherwise pull from the HF model repo, cached on disk.
"""
local_dir = os.environ.get("DV_LOCAL_CKPT_DIR")
if local_dir:
local_path = Path(local_dir) / filename
if local_path.exists():
return str(local_path)
raise FileNotFoundError(
f"DV_LOCAL_CKPT_DIR is set but {local_path} does not exist."
)
return hf_hub_download(
repo_id=HF_CKPT_REPO,
filename=filename,
token=os.environ.get("HF_TOKEN"),
)
def _compose_inference_cfg(wav_path: str, ckpt_path: str, data_sr: int, threshold: float):
"""Build the OmegaConf cfg for a single-file inference run."""
if GlobalHydra.instance().is_initialized():
GlobalHydra.instance().clear()
# config_path is relative to *this file*; soundbay/ is a sibling of helpers.py
with initialize(config_path="soundbay/conf", version_base="1.2"):
cfg = compose(
config_name="runs/inference_single_audio",
overrides=[
f"experiment.checkpoint.path={ckpt_path}",
f"data.test_dataset.file_path={wav_path}",
f"data.data_sample_rate={data_sr}",
f"experiment.threshold={threshold}",
"experiment.save_raven=true",
],
)
return cfg
def run_inference(
wav_path: str,
ckpt_path: str,
threshold: float,
output_dir: str | Path,
) -> tuple[Path, Optional[Path]]:
"""Run inference on one wav and return (csv_path, raven_path-or-None).
Picks `data_sample_rate` from the wav file itself (not whatever the model
was trained on). Only constraint: wav SR must be >= the model's internal
sample rate, otherwise we'd be making up information that isn't there.
"""
ckpt_dict = torch.load(ckpt_path, map_location="cpu", weights_only=False)
model_sr = int(ckpt_dict["args"].data.sample_rate)
wav_sr = int(sf.info(wav_path).samplerate)
if wav_sr < model_sr:
raise ValueError(
f"{Path(wav_path).name}: sample rate {wav_sr} Hz is below this "
f"model's required minimum of {model_sr} Hz — upload a higher-rate recording."
)
cfg = _compose_inference_cfg(wav_path, ckpt_path, wav_sr, threshold)
cfg = merge_with_checkpoint(cfg, ckpt_dict["args"])
state_dict = ckpt_dict["model"]
default_norm = "softmax" if cfg.data.label_type == "single_label" else "sigmoid"
out = Path(output_dir)
out.mkdir(exist_ok=True, parents=True)
model_name = Path(ckpt_path).parent.stem
inference_to_file(
device=torch.device("cpu"),
batch_size=cfg.data.batch_size,
dataset_args=cfg.data.test_dataset,
model_args=cfg.model.model,
checkpoint_state_dict=state_dict,
output_path=out,
model_name=model_name,
save_raven=cfg.experiment.save_raven,
threshold=cfg.experiment.threshold,
label_names=cfg.data.label_names,
raven_max_freq=cfg.experiment.raven_max_freq,
proba_norm_func=cfg.data.get("proba_norm_func", default_norm),
label_type=cfg.data.label_type,
)
wav_stem = Path(wav_path).stem
csvs = sorted(out.glob(f"Inference_results-*-{model_name}-{wav_stem}.csv"))
ravens = sorted(out.glob(f"{wav_stem}-Raven-inference_results-*-{model_name}.txt"))
if not csvs:
raise RuntimeError(f"No CSV output produced for {wav_path}")
# Rewrite the CSV's `filename` column to just the basename — the original
# value is the gradio temp upload path (/tmp/gradio/<hash>/...) which is
# noisy and useless to the user.
wav_basename = Path(wav_path).name
df = pd.read_csv(csvs[-1])
df["filename"] = wav_basename
df.to_csv(csvs[-1], index=False)
return csvs[-1], (ravens[-1] if ravens else None)
def merge_ravens(
raven_paths: list[Optional[Path]],
audio_paths: list[Path],
output_path: Path,
) -> Path:
"""Concatenate per-file Raven TSVs into one, with offset times and a
`Begin File` column. Mirrors `scripts/merge_multiple_ravens_to_one_file.py`
but without its CLI / strict-count assertions."""
df_list: list[pd.DataFrame] = []
seconds_offset = 0.0
entries_offset = 0
for raven_p, audio_p in zip(raven_paths, audio_paths):
if raven_p is None or not Path(raven_p).exists():
# Still need to advance the time offset by this file's duration.
seconds_offset += sf.info(str(audio_p)).duration
continue
df = pd.read_csv(raven_p, sep="\t")
if len(df):
df["Begin Time (s)"] = df["Begin Time (s)"] + seconds_offset
df["End Time (s)"] = df["End Time (s)"] + seconds_offset
df["Selection"] = df["Selection"] + entries_offset
df["Begin File"] = [Path(audio_p).name] * df.shape[0]
df_list.append(df)
entries_offset += df.shape[0]
seconds_offset += sf.info(str(audio_p)).duration
if df_list:
pd.concat(df_list).to_csv(output_path, sep="\t", index=False)
else:
pd.DataFrame(columns=_RAVEN_COLS).to_csv(output_path, sep="\t", index=False)
return output_path
def validate_uploads(file_paths: list[str]) -> dict:
"""Inspect uploads; return summary dict. Raises gr.Error on cap violation."""
import gradio as gr # local import so helpers stays usable in non-Gradio contexts
if not file_paths:
raise gr.Error("Please upload at least one .wav file.")
total_bytes = 0
durations: list[float] = []
for fp in file_paths:
p = Path(fp)
if p.suffix.lower() != ".wav":
raise gr.Error(f"Not a .wav file: {p.name}")
total_bytes += p.stat().st_size
try:
durations.append(sf.info(str(p)).duration)
except Exception as e:
raise gr.Error(f"Cannot read {p.name}: {e}")
if total_bytes > MAX_TOTAL_BYTES:
raise gr.Error(
f"Total size {total_bytes/1e6:.1f} MB > {MAX_TOTAL_BYTES/1e6:.0f} MB."
)
return {
"n_files": len(file_paths),
"total_mb": total_bytes / 1e6,
"total_min": sum(durations) / 60,
"durations": durations,
}
def estimate_minutes(total_audio_seconds: float, coef: float = 0.2) -> float:
"""Upper-bound runtime estimate assuming `coef` x real-time on CPU."""
return (total_audio_seconds * coef) / 60.0
def zip_files(paths: list[Path], zip_path: Path) -> Path:
"""Bundle the given paths into a single zip (flat layout)."""
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in paths:
zf.write(p, arcname=Path(p).name)
return zip_path