From cda2ebed4a1cfafc42c016f9bdf8f35a23dca2ab Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 6 Jul 2026 16:24:44 +0200 Subject: [PATCH 1/2] --merge_consecutive x now merges UP TO x segments --- birdnet_analyzer/analyze/core.py | 11 ++++-- birdnet_analyzer/cli.py | 2 +- tests/analyze/test_core_merge.py | 63 +++++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 82374db68..727b8046c 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -379,7 +379,9 @@ def _merge_consecutive_segments( Input predictions containing at least "input", "species_name", "start_time", "end_time" and "confidence". merge_consecutive : int - Number of consecutive rows that must be contiguous to merge them. + Maximum number of consecutive contiguous rows to collapse into a single + segment. Runs longer than this are split into chunks of at most + ``merge_consecutive`` rows; runs shorter than it are still merged. hop_size : float, optional Allowed tolerance (in seconds) between the end of one segment and the start of the next to consider them consecutive, by default 3.0. @@ -445,7 +447,8 @@ def _merge_consecutive_segments( else: break - if len(window) == merge_consecutive: + # Merge whatever consecutive rows we collected (1 up to merge_consecutive). + if len(window) > 1: merged_row = window[0].copy() merged_row["start_time"] = window[0]["start_time"] merged_row["end_time"] = window[-1]["end_time"] @@ -461,10 +464,10 @@ def _merge_consecutive_segments( merged_row["confidence"] = avg_confidence merged_records.append(merged_row.to_dict()) - i += merge_consecutive else: merged_records.append(window[0].to_dict()) - i += 1 + + i += len(window) merged_df = pd.DataFrame.from_records(merged_records, columns=df.columns) return merged_df.sort_values(by=["input", "start_time", "end_time", "species_name"]) diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index de08dddcb..8f61666e7 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -500,7 +500,7 @@ def __call__(self, parser, namespace, values, option_string=None): "--merge_consecutive", type=int, default=1, - help="Maximum number of consecutive detections above MIN_CONF to merge for each detected species. This will result in fewer entires in the result file with segments longer than 3 seconds. Set to 0 or 1 to disable merging. Set to None to include all consecutive detections. We use the mean of the top 3 scores from all consecutive detections for merging.", + help="Maximum number of consecutive detections above the threshold to merge for each detected species. This will result in fewer entires in the result file with segments longer than 3 seconds. Set to 0 or 1 to disable merging. We use the mean of the top 3 scores from all consecutive detections for merging.", ) parser.add_argument( "--use_perch", diff --git a/tests/analyze/test_core_merge.py b/tests/analyze/test_core_merge.py index d4b30230f..38c19fcbe 100644 --- a/tests/analyze/test_core_merge.py +++ b/tests/analyze/test_core_merge.py @@ -67,7 +67,7 @@ def test_merge_consecutive_segments_merges_expected_rows(): assert merged_records[2]["confidence"] == pytest.approx(0.6) -def test_merge_consecutive_segments_requires_full_window(): +def test_merge_consecutive_segments_merges_full_window(): df = pd.DataFrame( [ { @@ -103,6 +103,67 @@ def test_merge_consecutive_segments_requires_full_window(): assert record["confidence"] == pytest.approx((0.25 + 0.75 + 0.5) / 3) +def test_merge_consecutive_segments_merges_up_to_the_window_size(): + # A run of 5 consecutive segments with merge_consecutive=3 must be split into + # chunks of at most 3: one merged row of 3 followed by one merged row of 2. + df = pd.DataFrame( + [ + { + "input": "file.wav", + "species_name": "species", + "start_time": float(i), + "end_time": float(i + 1), + "confidence": 0.1 * (i + 1), + } + for i in range(5) + ] + ) + + merged = _merge_consecutive_segments(df, merge_consecutive=3, hop_size=1.0) + + assert len(merged) == 2 + + records = merged.to_dict("records") + + assert records[0]["start_time"] == 0.0 + assert records[0]["end_time"] == 3.0 + assert records[0]["confidence"] == pytest.approx((0.1 + 0.2 + 0.3) / 3) + + assert records[1]["start_time"] == 3.0 + assert records[1]["end_time"] == 5.0 + assert records[1]["confidence"] == pytest.approx((0.4 + 0.5) / 2) + + +def test_merge_consecutive_segments_merges_partial_run_shorter_than_window(): + # A run shorter than merge_consecutive must still be merged (up to N). + df = pd.DataFrame( + [ + { + "input": "file.wav", + "species_name": "species", + "start_time": 0.0, + "end_time": 1.0, + "confidence": 0.25, + }, + { + "input": "file.wav", + "species_name": "species", + "start_time": 1.0, + "end_time": 2.0, + "confidence": 0.75, + }, + ] + ) + merged = _merge_consecutive_segments(df, merge_consecutive=3, hop_size=1.0) + + assert len(merged) == 1 + + record = merged.to_dict("records")[0] + assert record["start_time"] == 0.0 + assert record["end_time"] == 2.0 + assert record["confidence"] == pytest.approx((0.25 + 0.75) / 2) + + def test_merge_consecutive_segments_handles_float16_time_columns(): df = pd.DataFrame( [ From 107f51ede245d73aceb2d93f12e208df1ce5f09f Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 6 Jul 2026 16:40:01 +0200 Subject: [PATCH 2/2] fix spelling mistake --- birdnet_analyzer/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/birdnet_analyzer/cli.py b/birdnet_analyzer/cli.py index 8f61666e7..0c7e9e65c 100644 --- a/birdnet_analyzer/cli.py +++ b/birdnet_analyzer/cli.py @@ -500,7 +500,7 @@ def __call__(self, parser, namespace, values, option_string=None): "--merge_consecutive", type=int, default=1, - help="Maximum number of consecutive detections above the threshold to merge for each detected species. This will result in fewer entires in the result file with segments longer than 3 seconds. Set to 0 or 1 to disable merging. We use the mean of the top 3 scores from all consecutive detections for merging.", + help="Maximum number of consecutive detections above the threshold to merge for each detected species. This will result in fewer entries in the result file with segments longer than 3 seconds. Set to 0 or 1 to disable merging. We use the mean of the top 3 scores from all consecutive detections for merging.", ) parser.add_argument( "--use_perch",