Compare commits

...

11 Commits

Author SHA1 Message Date
Santiago Martinez Balvanera
833db23855
Merge pull request #71 from macaodha/enhancement/logs-to-file
Some checks are pending
CI / Checks (push) Waiting to run
CI / Tests (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Tests (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / Tests (Python ${{ matrix.python-version }}) (3.12) (push) Waiting to run
Docs Pages / Build Docs (push) Waiting to run
Docs Pages / Deploy Docs (push) Blocked by required conditions
Add CLI log file output
2026-06-22 21:39:12 -06:00
mbsantiago
7bd0521d01 Run formatter 2026-06-22 21:36:14 -06:00
mbsantiago
89aa74afde docs: clarify CLI log file option 2026-06-22 21:31:09 -06:00
mbsantiago
0a8787e5f1 feat: add CLI log file output 2026-06-22 21:17:20 -06:00
Santiago Martinez Balvanera
920eea690f
Merge pull request #70 from macaodha/fix/GH-68-skip-over-invalid-files
fix: skip unreadable audio files during batch processing
2026-06-22 21:04:32 -06:00
mbsantiago
2d969ba50f fix: skip unreadable audio files in batch processing 2026-06-22 20:57:45 -06:00
Santiago Martinez Balvanera
f80fc3f8f4
Merge pull request #67 from SAY-5/fix-np-int-visualize
Replace removed np.int alias with int in visualize
2026-06-22 20:42:13 -06:00
Santiago Martinez Balvanera
4f8b6ee53d
Merge pull request #69 from macaodha/fix/GH-66-fix-features
fix: merge clip outputs before saving batdetect2 format
2026-06-22 20:38:32 -06:00
mbsantiago
3b34f467c6 fix: merge clip outputs for batdetect2 format 2026-06-22 20:29:25 -06:00
mbsantiago
5c300d883f Rename plot_clip_detections to plot_clip_evals and add plot_detection 2026-06-22 20:11:17 -06:00
Sai Asish Y
7446cc09b2 Replace removed np.int alias with int in visualize
Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
2026-06-18 12:40:36 -07:00
14 changed files with 509 additions and 11 deletions

View File

@ -17,6 +17,8 @@ for the full option list.
## Notes ## Notes
- Global CLI options are documented in {doc}`base`. - Global CLI options are documented in {doc}`base`.
- Use `--log-file path/to/cli.log` to save CLI logs to a file while still
showing them in the terminal.
- Paths with spaces should be wrapped in quotes. - Paths with spaces should be wrapped in quotes.
- Input audio is expected to be mono. - Input audio is expected to be mono.
- `process` uses the optional `--detection-threshold` override. - `process` uses the optional `--detection-threshold` override.

View File

@ -97,6 +97,23 @@ What this does:
- runs the model on each recording, - runs the model on each recording,
- saves the results in `path/to/outputs`. - saves the results in `path/to/outputs`.
```{eval-rst}
.. admonition:: Save CLI logs to a file
:collapsible: closed
:class: tip dropdown
If you want to keep a copy of the CLI logs, add ``--log-file`` before the
subcommand:
.. code-block:: bash
batdetect2 --log-file path/to/cli.log process directory \
path/to/audio \
path/to/outputs
This writes the same CLI logs to ``path/to/cli.log`` and to the terminal.
```
You do not need to choose a model for this first run. You do not need to choose a model for this first run.
If you do nothing, BatDetect2 uses the built-in default UK model. If you do nothing, BatDetect2 uses the built-in default UK model.

View File

@ -1,5 +1,7 @@
"""BatDetect2 command line interface.""" """BatDetect2 command line interface."""
from pathlib import Path
import click import click
from batdetect2.cli.ascii import BATDETECT_ASCII_ART from batdetect2.cli.ascii import BATDETECT_ASCII_ART
@ -22,8 +24,17 @@ BatDetect2
count=True, count=True,
help="Increase verbosity. -v for INFO, -vv for DEBUG.", help="Increase verbosity. -v for INFO, -vv for DEBUG.",
) )
@click.option(
"--log-file",
type=click.Path(path_type=Path),
help="Write CLI logs to a file in addition to the terminal.",
)
@click.pass_context @click.pass_context
def cli(ctx: click.Context, verbose: int = 0): def cli(
ctx: click.Context,
verbose: int = 0,
log_file: Path | None = None,
) -> None:
"""Run the BatDetect2 CLI. """Run the BatDetect2 CLI.
Use subcommands to run processing, training, evaluation, and dataset Use subcommands to run processing, training, evaluation, and dataset
@ -37,4 +48,4 @@ def cli(ctx: click.Context, verbose: int = 0):
from batdetect2.logging import enable_logging from batdetect2.logging import enable_logging
enable_logging(verbose) enable_logging(verbose, log_file=log_file)

View File

@ -21,7 +21,7 @@ from batdetect2.core import ImportConfig, Registry, add_import_config
from batdetect2.evaluate.metrics.common import compute_precision_recall from batdetect2.evaluate.metrics.common import compute_precision_recall
from batdetect2.evaluate.metrics.detection import ClipEval from batdetect2.evaluate.metrics.detection import ClipEval
from batdetect2.evaluate.plots.base import BasePlot, BasePlotConfig from batdetect2.evaluate.plots.base import BasePlot, BasePlotConfig
from batdetect2.plotting.detections import plot_clip_detections from batdetect2.plotting.detections import plot_clip_evaluation
from batdetect2.plotting.metrics import plot_pr_curve, plot_roc_curve from batdetect2.plotting.metrics import plot_pr_curve, plot_roc_curve
from batdetect2.preprocess import PreprocessingConfig, build_preprocessor from batdetect2.preprocess import PreprocessingConfig, build_preprocessor
from batdetect2.preprocess.types import PreprocessorProtocol from batdetect2.preprocess.types import PreprocessorProtocol
@ -276,7 +276,7 @@ class ExampleDetectionPlot(BasePlot):
fig = self.create_figure() fig = self.create_figure()
ax = fig.subplots() ax = fig.subplots()
plot_clip_detections( plot_clip_evaluation(
clip_eval, clip_eval,
ax=ax, ax=ax,
audio_loader=self.audio_loader, audio_loader=self.audio_loader,

View File

@ -2,7 +2,9 @@ from typing import List, Sequence
from uuid import uuid5 from uuid import uuid5
import numpy as np import numpy as np
from loguru import logger
from soundevent import data from soundevent import data
from soundfile import LibsndfileError
def get_clips_from_files( def get_clips_from_files(
@ -16,7 +18,15 @@ def get_clips_from_files(
clips: List[data.Clip] = [] clips: List[data.Clip] = []
for path in paths: for path in paths:
recording = data.Recording.from_file(path, compute_hash=compute_hash) try:
recording = data.Recording.from_file(
path,
compute_hash=compute_hash,
)
except LibsndfileError as e:
logger.warning(f"Skipping unreadable audio file {path}: {e}")
continue
clips.extend( clips.extend(
get_recording_clips( get_recording_clips(
recording, recording,

View File

@ -50,7 +50,7 @@ __all__ = [
] ]
def enable_logging(level: int): def enable_logging(level: int, log_file: Path | None = None) -> None:
logger.remove() logger.remove()
if level == 0: if level == 0:
@ -61,6 +61,11 @@ def enable_logging(level: int):
log_level = "DEBUG" log_level = "DEBUG"
logger.add(sys.stderr, level=log_level) logger.add(sys.stderr, level=log_level)
if log_file is not None:
log_file.parent.mkdir(parents=True, exist_ok=True)
logger.add(log_file, level=log_level)
logger.enable("batdetect2") logger.enable("batdetect2")

View File

@ -1,4 +1,5 @@
import json import json
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import List, Literal, Sequence, TypedDict, cast from typing import List, Literal, Sequence, TypedDict, cast
@ -93,8 +94,11 @@ class BatDetect2Formatter(OutputFormatterProtocol[FileAnnotation]):
def format( def format(
self, predictions: Sequence[ClipDetections] self, predictions: Sequence[ClipDetections]
) -> List[FileAnnotation]: ) -> List[FileAnnotation]:
merged_predictions = merge_clip_detections(predictions)
return [ return [
self.format_prediction(prediction) for prediction in predictions self.format_prediction(prediction)
for prediction in merged_predictions
] ]
def save( def save(
@ -349,3 +353,48 @@ class BatDetect2Formatter(OutputFormatterProtocol[FileAnnotation]):
preserve_audio_tree=config.preserve_audio_tree, preserve_audio_tree=config.preserve_audio_tree,
include_file_path=config.include_file_path, include_file_path=config.include_file_path,
) )
def merge_clip_detections(
predictions: Sequence[ClipDetections],
) -> List[ClipDetections]:
"""Merge clip predictions into one recording-level prediction.
This intentionally discards the original clip boundaries because the
legacy BatDetect2 file format only stores recording-level detections.
"""
rec_to_clips = defaultdict(list)
rec_mapping = {}
for prediction in predictions:
recording = prediction.clip.recording
key = recording.path
rec_to_clips[key].append(prediction)
rec_mapping[key] = recording
merged_predictions = []
for rec_path, clips in rec_to_clips.items():
recording = rec_mapping[rec_path]
merged_predictions.append(
ClipDetections(
clip=data.Clip(
recording=recording,
start_time=0,
end_time=recording.duration,
),
detections=sorted(
[
detection
for clip_detections in clips
for detection in clip_detections.detections
],
key=lambda detection: (
detection.detection_score,
*compute_bounds(detection.geometry),
),
reverse=True,
),
)
)
return merged_predictions

View File

@ -1,4 +1,6 @@
import numpy as np
from matplotlib import axes, patches from matplotlib import axes, patches
from soundevent.geometry import compute_bounds
from soundevent.plot import plot_geometry from soundevent.plot import plot_geometry
from batdetect2.evaluate.metrics.detection import ClipEval from batdetect2.evaluate.metrics.detection import ClipEval
@ -8,13 +10,111 @@ from batdetect2.plotting.clips import (
plot_clip, plot_clip,
) )
from batdetect2.plotting.common import create_ax from batdetect2.plotting.common import create_ax
from batdetect2.postprocess import ClipDetections, Detection
__all__ = [ __all__ = [
"plot_clip_detections", "plot_clip_evaluation",
"plot_detection",
] ]
def plot_clip_detections( def plot_detection(
detection: Detection,
figsize: tuple[int, int] = (10, 10),
ax: axes.Axes | None = None,
fill: bool = False,
linewidth: float = 1.0,
linestyle: str = "--",
color: str = "red",
show_class: bool = True,
class_names: list[str] | None = None,
fontsize: float | str = "small",
):
ax = create_ax(figsize=figsize, ax=ax)
plot_geometry(
detection.geometry,
ax=ax,
add_points=False,
facecolor="none" if not fill else color,
alpha=detection.detection_score,
linewidth=linewidth,
linestyle=linestyle,
color=color,
)
if not show_class:
return ax
start_time, low_freq, _, _ = compute_bounds(detection.geometry)
top_class = np.argmax(detection.class_scores)
score = detection.class_scores[top_class]
if class_names is not None:
class_name = class_names[top_class]
else:
class_name = f"class {top_class}"
ax.text(
start_time,
low_freq,
f"{class_name}={score:.2f}",
va="top",
ha="left",
color=color,
fontsize=fontsize,
alpha=detection.detection_score,
)
return ax
def plot_clip_detection(
clip_detections: ClipDetections,
figsize: tuple[int, int] = (10, 10),
ax: axes.Axes | None = None,
audio_loader: AudioLoader | None = None,
preprocessor: PreprocessorProtocol | None = None,
threshold: float | None = None,
spec_cmap: str = "gray",
fill: bool = False,
linewidth: float = 1.0,
linestyle: str = "--",
color: str = "red",
show_class: bool = True,
class_names: list[str] | None = None,
fontsize: float | str = "small",
):
ax = create_ax(figsize=figsize, ax=ax)
plot_clip(
clip_detections.clip,
audio_loader=audio_loader,
preprocessor=preprocessor,
ax=ax,
spec_cmap=spec_cmap,
)
for detection in clip_detections.detections:
if threshold and detection.detection_score < threshold:
continue
ax = plot_detection(
detection,
ax=ax,
class_names=class_names,
fontsize=fontsize,
fill=fill,
linewidth=linewidth,
linestyle=linestyle,
color=color,
show_class=show_class,
)
return ax
def plot_clip_evaluation(
clip_eval: ClipEval, clip_eval: ClipEval,
figsize: tuple[int, int] = (10, 10), figsize: tuple[int, int] = (10, 10),
ax: axes.Axes | None = None, ax: axes.Axes | None = None,

View File

@ -52,9 +52,9 @@ class InteractivePlotter:
self.spec_slices = spec_slices self.spec_slices = spec_slices
self.call_info = call_info self.call_info = call_info
# _, self.labels = np.unique([cc['class'] for cc in call_info], return_inverse=True) # _, self.labels = np.unique([cc['class'] for cc in call_info], return_inverse=True)
self.labels = np.zeros(len(call_info), dtype=np.int) self.labels = np.zeros(len(call_info), dtype=int)
self.annotated = np.zeros( self.annotated = np.zeros(
self.labels.shape[0], dtype=np.int self.labels.shape[0], dtype=int
) # can populate this with 1's where we have labels ) # can populate this with 1's where we have labels
self.labels_cols = [ self.labels_cols = [
colors[self.labels[ii]] for ii in range(len(self.labels)) colors[self.labels[ii]] for ii in range(len(self.labels))

View File

@ -1,9 +1,13 @@
"""Behavior-focused tests for top-level CLI command discovery.""" """Behavior-focused tests for top-level CLI command discovery."""
from pathlib import Path
from click.testing import CliRunner from click.testing import CliRunner
from batdetect2.cli import cli from batdetect2.cli import cli
BASE_DIR = Path(__file__).parent.parent.parent
def test_cli_base_help_lists_main_commands() -> None: def test_cli_base_help_lists_main_commands() -> None:
"""User story: discover available workflows from top-level help.""" """User story: discover available workflows from top-level help."""
@ -11,8 +15,43 @@ def test_cli_base_help_lists_main_commands() -> None:
result = CliRunner().invoke(cli, ["--help"]) result = CliRunner().invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "--log-file" in result.output
assert "process" in result.output assert "process" in result.output
assert "train" in result.output assert "train" in result.output
assert "evaluate" in result.output assert "evaluate" in result.output
assert "data" in result.output assert "data" in result.output
assert "detect" in result.output assert "detect" in result.output
def test_cli_writes_logs_to_file_and_terminal(
tmp_path: Path,
tiny_checkpoint_path: Path,
) -> None:
"""User story: save CLI logs to a file while keeping terminal output."""
output_dir = tmp_path / "eval_out"
log_file = tmp_path / "logs" / "cli.log"
result = CliRunner().invoke(
cli,
[
"--log-file",
str(log_file),
"-v",
"evaluate",
str(BASE_DIR / "example_data" / "dataset.yaml"),
"--model",
str(tiny_checkpoint_path),
"--base-dir",
str(BASE_DIR),
"--workers",
"0",
"--output-dir",
str(output_dir),
],
)
assert result.exit_code == 0
assert "Initiating evaluation process..." in result.output
assert log_file.exists()
assert "Initiating evaluation process..." in log_file.read_text()

View File

@ -0,0 +1,131 @@
import json
import shutil
from collections import Counter
from pathlib import Path
from click.testing import CliRunner
from soundevent.geometry import compute_bounds
from batdetect2 import BatDetect2API
from batdetect2.cli import cli
def test_cli_process_directory_merges_clip_outputs_per_recording(
tmp_path: Path,
contrib_dir: Path,
) -> None:
recording_path = contrib_dir / "jeff37" / "0166_20240531_223911.wav"
source_folder = tmp_path / "audio"
source_folder.mkdir()
shutil.copy2(
recording_path,
source_folder / "example_audio.wav",
)
destination_folder = tmp_path / "results"
destination_folder.mkdir()
api = BatDetect2API.from_checkpoint()
api_outputs = api.process_directory(
source_folder,
detection_threshold=0.3,
)
# Get all detections regardless of clip
detections = [
detection
for clip_detections in api_outputs
for detection in clip_detections.detections
]
result = CliRunner().invoke(
cli,
args=[
"process",
"directory",
str(source_folder),
str(destination_folder),
"--detection-threshold",
"0.3",
],
)
assert result.exit_code == 0
assert destination_folder.exists()
output_json = destination_folder / "example_audio.wav.json"
assert output_json.exists()
saved_detections = json.loads(output_json.read_text())
expected_annotations = Counter(
(
round(float(start_time), 4),
round(float(end_time), 4),
int(low_freq),
int(high_freq),
round(float(detection.class_scores.max()), 3),
round(float(detection.detection_score), 3),
)
for detection in detections
for start_time, low_freq, end_time, high_freq in [
compute_bounds(detection.geometry)
]
)
actual_annotations = Counter(
(
annotation["start_time"],
annotation["end_time"],
annotation["low_freq"],
annotation["high_freq"],
annotation["class_prob"],
annotation["det_prob"],
)
for annotation in saved_detections["annotation"]
)
assert actual_annotations == expected_annotations
def test_cli_process_directory_skips_corrupted_files(
tmp_path: Path,
contrib_dir: Path,
) -> None:
recording_path = contrib_dir / "jeff37" / "0166_20240531_223911.wav"
source_folder = tmp_path / "audio"
source_folder.mkdir()
shutil.copy2(
recording_path,
source_folder / "example_audio.wav",
)
corrupted_file = source_folder / "corrupted.wav"
corrupted_file.write_text("corrupted")
destination_folder = tmp_path / "results"
destination_folder.mkdir()
result = CliRunner().invoke(
cli,
args=[
"process",
"directory",
str(source_folder),
str(destination_folder),
"--detection-threshold",
"0.3",
],
)
assert result.exit_code == 0
assert destination_folder.exists()
output_json = destination_folder / "example_audio.wav.json"
assert output_json.exists()
corrupted_file_json = destination_folder / "corrupted.wav.json"
assert not corrupted_file_json.exists()

View File

@ -0,0 +1,55 @@
from pathlib import Path
import pytest
from soundevent import data
from batdetect2.inference.batch import run_batch_inference
from batdetect2.targets import build_roi_mapping, build_targets
from batdetect2.train import load_model_from_checkpoint
from tests.utils import assert_clip_detections_equal
pytestmark = pytest.mark.slow
def test_run_batch_inference_matches_single_clip_inference(
contrib_dir: Path,
) -> None:
recording = data.Recording.from_file(
contrib_dir / "jeff37" / "0166_20240531_223911.wav"
)
clips = [
data.Clip(recording=recording, start_time=start, end_time=start + 1.0)
for start in (0.0, 1.0, 2.0)
]
model, configs = load_model_from_checkpoint()
targets = build_targets(configs.targets)
roi_mapper = build_roi_mapping(configs.targets.roi)
batched_predictions = run_batch_inference(
model,
clips,
targets=targets,
roi_mapper=roi_mapper,
batch_size=3,
num_workers=0,
)
single_predictions = [
run_batch_inference(
model,
[clip],
targets=targets,
roi_mapper=roi_mapper,
batch_size=1,
num_workers=0,
)[0]
for clip in clips
]
assert len(batched_predictions) == len(single_predictions)
for batched, single in zip(
batched_predictions,
single_predictions,
strict=True,
):
assert_clip_detections_equal(batched, single)

View File

@ -0,0 +1,22 @@
import numpy as np
from batdetect2.utils.visualize import InteractivePlotter
def test_interactive_plotter_init_builds_integer_label_arrays():
feats_ds = np.zeros((2, 2))
spec_slices = [np.zeros((4, 6)), np.zeros((4, 8))]
call_info = [{"class": "a"}, {"class": "b"}]
plotter = InteractivePlotter(
feats_ds=feats_ds,
feats=feats_ds,
spec_slices=spec_slices,
call_info=call_info,
freq_lims=[0, 1],
allow_training=False,
)
assert plotter.labels.shape == (2,)
assert np.issubdtype(plotter.labels.dtype, np.integer)
assert np.issubdtype(plotter.annotated.dtype, np.integer)

57
tests/utils.py Normal file
View File

@ -0,0 +1,57 @@
import numpy as np
from soundevent.geometry import compute_bounds
from batdetect2.postprocess.types import ClipDetections
def assert_clip_detections_equal(
detections: ClipDetections,
other: ClipDetections,
) -> None:
"""Assert two clip-detection objects are numerically equivalent."""
assert detections.clip.recording.path == other.clip.recording.path
assert detections.clip.start_time == other.clip.start_time
assert detections.clip.end_time == other.clip.end_time
assert len(detections.detections) == len(other.detections)
sorted_detections = sorted(
detections.detections,
key=lambda det: (
compute_bounds(det.geometry)[0],
compute_bounds(det.geometry)[1],
),
)
sorted_other = sorted(
other.detections,
key=lambda det: (
compute_bounds(det.geometry)[0],
compute_bounds(det.geometry)[1],
),
)
for det, other_det in zip(
sorted_detections,
sorted_other,
strict=True,
):
np.testing.assert_allclose(
np.array(compute_bounds(det.geometry)),
np.array(compute_bounds(other_det.geometry)),
atol=2e-2,
)
assert np.isclose(
det.detection_score,
other_det.detection_score,
atol=1e-6,
)
np.testing.assert_allclose(
det.class_scores,
other_det.class_scores,
atol=1e-6,
)
np.testing.assert_allclose(
det.features,
other_det.features,
atol=2e-6,
)