#!/usr/bin/env python3 """Export the authoritative BatDetect2 detector/classifier checkpoint to ONNX.""" from __future__ import annotations import argparse import hashlib import json import subprocess import sys from pathlib import Path EXPECTED_UK_LABELS = [ "MYOMYS", "MYOALC", "CNESER", "PIPNAT", "BARBAR", "MYONAT", "MYODAU", "MYOBRA", "PIPPIP", "MYOBEC", "PIPPYG", "RHIHIP", "NYCLEI", "RHIFER", "PLEAUR", "NYCNOC", "PLEAUS", ] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--source", type=Path, required=True, help="BatDetect2 repository checkout") parser.add_argument( "--checkpoint", type=Path, help="defaults to the checkout's bundled batdetect2_uk_same.ckpt", ) parser.add_argument("--output", type=Path, default=Path("models/batdetect2-uk-same.onnx")) parser.add_argument("--opset", type=int, default=17) parser.add_argument( "--accept-noncommercial-license", action="store_true", help="acknowledge that the checkpoint is CC BY-NC 4.0", ) return parser.parse_args() def main() -> None: args = parse_args() if not args.accept_noncommercial_license: raise SystemExit( "BatDetect2's bundled model is CC BY-NC 4.0; rerun with " "--accept-noncommercial-license after reviewing those terms" ) source = args.source.resolve() checkpoint = args.checkpoint or ( source / "src/batdetect2/models/checkpoints/batdetect2_uk_same.ckpt" ) if not checkpoint.is_file() or not (source / "src/batdetect2").is_dir(): raise SystemExit("--source or --checkpoint does not contain the expected BatDetect2 files") sys.path.insert(0, str(source / "src")) try: import numpy as np import onnx import onnxruntime as ort import torch from batdetect2.train import load_model_from_checkpoint except ImportError as exc: raise SystemExit( "Install BatDetect2 and ONNX export dependencies in an isolated environment; " "see docs/MODELS.md" ) from exc model, _ = load_model_from_checkpoint(checkpoint) source_labels = list(model.class_names) labels = [label.upper() for label in source_labels] if labels != EXPECTED_UK_LABELS: raise SystemExit( "checkpoint class order differs from the locked Android contract:\n" f"expected={EXPECTED_UK_LABELS}\nactual={source_labels}" ) class DetectorOnly(torch.nn.Module): def __init__(self, detector: torch.nn.Module) -> None: super().__init__() self.detector = detector def forward(self, input: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: output = self.detector(input) return output.detection_probs, output.class_probs wrapper = DetectorOnly(model.detector).eval() dummy = torch.zeros((1, 1, 128, 256), dtype=torch.float32) args.output.parent.mkdir(parents=True, exist_ok=True) with torch.inference_mode(): torch.onnx.export( wrapper, (dummy,), args.output, input_names=["input"], output_names=["detection_probs", "class_probs"], opset_version=args.opset, do_constant_folding=True, dynamo=False, ) onnx.checker.check_model(onnx.load(args.output)) with torch.inference_mode(): detection, classes = wrapper(dummy) torch.manual_seed(20260818) parity_input = torch.rand((1, 1, 128, 256), dtype=torch.float32) reference_detection, reference_classes = wrapper(parity_input) runtime = ort.InferenceSession(str(args.output), providers=["CPUExecutionProvider"]) converted_detection, converted_classes = runtime.run( ["detection_probs", "class_probs"], {runtime.get_inputs()[0].name: parity_input.numpy()}, ) detection_error = float(np.max(np.abs(reference_detection.numpy() - converted_detection))) class_error = float(np.max(np.abs(reference_classes.numpy() - converted_classes))) maximum_absolute_error = max(detection_error, class_error) if maximum_absolute_error > 1e-4: raise SystemExit( f"PyTorch/ONNX parity failed: max absolute error {maximum_absolute_error:.8g}" ) try: source_revision = subprocess.run( ["git", "-C", str(source), "rev-parse", "HEAD"], check=True, capture_output=True, text=True, ).stdout.strip() except (OSError, subprocess.CalledProcessError): source_revision = "unknown" try: checkpoint_identity = str(checkpoint.resolve().relative_to(source)) except ValueError: checkpoint_identity = checkpoint.name metadata = { "backend": "batdetect2", "source": "https://github.com/macaodha/batdetect2", "source_revision": source_revision, "checkpoint": checkpoint_identity, "input_name": "input", "input_shape": [1, 1, 128, 256], "outputs": { "detection_probs": list(detection.shape), "class_probs": list(classes.shape), }, "labels": labels, "source_labels": source_labels, "opset": args.opset, "license": "CC BY-NC 4.0", "commercial_use": False, "onnx_bytes": args.output.stat().st_size, "onnx_sha256": hashlib.sha256(args.output.read_bytes()).hexdigest(), "source_runtime_parity": { "fixture": "seeded random float32 tensor (seed 20260818)", "detection_maximum_absolute_error": detection_error, "class_maximum_absolute_error": class_error, "tolerance": 1e-4, }, } args.output.with_suffix(".json").write_text(json.dumps(metadata, indent=2) + "\n") print(f"wrote {args.output}") if __name__ == "__main__": main()