diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..ce0a85d0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Version control and CI +.git +.github + +# Python build/cache artifacts +__pycache__ +*.pyc +*.egg-info +build +dist + +# Local environments +.venv* +venv + +# Large local-only artifacts (untracked, not needed at runtime). +# The BirdNET model is downloaded by the `birdnet` dependency on first run, +# so bundled checkpoints are not required in the image. +installers +birdnet_analyzer/checkpoints + +# Not needed inside the image +tests +docs +.pytest_cache +.ruff_cache +.vscode +.idea +Dockerfile +.dockerignore diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 2622a344a..f51a2c0e6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,48 +1,137 @@ -name: Docker Build +name: Docker on: push: - branches: [ main ] + branches: [main] paths: - - 'Dockerfile' - - '.github/workflows/docker-build.yml' + - "Dockerfile" + - ".dockerignore" + - ".github/workflows/docker-build.yml" pull_request: - branches: [ main ] + branches: [main] paths: - - 'Dockerfile' - - '.github/workflows/docker-build.yml' + - "Dockerfile" + - ".dockerignore" + - ".github/workflows/docker-build.yml" + release: + types: [published] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + REGISTRY: ghcr.io + # metadata-action lowercases this to ghcr.io/birdnet-team/birdnet-analyzer + IMAGE_NAME: ${{ github.repository }} jobs: - build: + test: + name: Build and test image runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build . --file Dockerfile --tag birdnet:local-test - - # Optional: Add basic test to verify the image works - - name: Test Docker image - run: | - docker run -v $PWD/birdnet_analyzer/example:/audio birdnet:local-test -m birdnet_analyzer.analyze /audio --slist /audio - - # Verify output file content - expected_header="Selection View Channel Begin Time (s) End Time (s) Low Freq (Hz) High Freq (Hz) Common Name Species Code Confidence Begin Path File Offset (s)" - expected_first_line="1 Spectrogram 1 1 0.0 3.0 0 15000 Black-capped Chickadee bkcchi 0.8141 /audio/soundscape.wav 0.0" - - actual_header=$(head -n 1 birdnet_analyzer/example/soundscape.BirdNET.selection.table.txt) - actual_first_line=$(head -n 2 birdnet_analyzer/example/soundscape.BirdNET.selection.table.txt | tail -n 1) - - if [ "$actual_header" != "$expected_header" ] || [ "$actual_first_line" != "$expected_first_line" ] - then - echo "Output file content does not match expected content" - echo "Expected header: $expected_header" - echo "Actual header: $actual_header" - echo "Expected first line: $expected_first_line" - echo "Actual first line: $actual_first_line" + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: birdnet:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test image + run: | + # Run inference end-to-end: mount the example audio read-only and + # write results to a separate directory so nothing is polluted. + mkdir -p out + docker run --rm \ + -v "$PWD/birdnet_analyzer/example:/audio:ro" \ + -v "$PWD/out:/out" \ + birdnet:test -m birdnet_analyzer.analyze /audio --slist /audio/species_list.txt -o /out + + output_file="out/BirdNET_SelectionTable.txt" + + # The container must produce a non-empty Raven selection table. + if [ ! -s "$output_file" ]; then + echo "::error::Expected output '$output_file' was not created or is empty" + ls -la out + exit 1 + fi + + # It must contain a header plus at least one detection row. + line_count=$(wc -l < "$output_file") + if [ "$line_count" -lt 2 ]; then + echo "::error::Output table has no detection rows ($line_count lines)" + cat "$output_file" exit 1 - else - echo "test received expected output file contents" - fi \ No newline at end of file + fi + + # Sanity-check the header exposes the expected Raven columns + # (structure only — exact model scores are covered by the pytest suite). + header=$(head -n 1 "$output_file") + for col in "Selection" "Begin Time (s)" "Confidence" "Begin Path"; do + case "$header" in + *"$col"*) ;; + *) echo "::error::Output header is missing column: $col"; echo "Header: $header"; exit 1 ;; + esac + done + + echo "Docker image smoke test passed ($((line_count - 1)) detections)" + + publish: + name: Publish to GitHub Container Registry + needs: test + if: github.event_name == 'release' + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # A release tagged v2.4.0 produces the tags 2.4.0, 2.4 and latest + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + # Avoid "unknown/unknown" attestation entries in the GHCR package UI + provenance: false diff --git a/Dockerfile b/Dockerfile index c5d2de71f..0cc7bf7a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,19 @@ -# Build from Python slim -FROM python:3.11 +# Match the Python version used to build the release (see publish.yml / CI matrix) +FROM python:3.12-slim # Install required packages while keeping the image small RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* # Import all scripts +WORKDIR /app COPY . ./ -# Install required Python packages -RUN pip3 install --no-cache-dir . +# Install the package, then replace the default TensorFlow build with the +# CPU-only wheel. BirdNET-Analyzer runs inference on CPU (the birdnet library +# uses ai-edge-litert), so the GPU-enabled TensorFlow build is dead weight. +RUN pip3 install --no-cache-dir . \ + && pip3 uninstall -y tensorflow \ + && pip3 install --no-cache-dir "tensorflow-cpu>=2.20" # Add entry point to run the script ENTRYPOINT [ "python3" ] diff --git a/docs/usage/docker.rst b/docs/usage/docker.rst index 555d8278d..698082904 100644 --- a/docs/usage/docker.rst +++ b/docs/usage/docker.rst @@ -1,4 +1,31 @@ Docker ====== -We are currently re-working our Docker setup. Please check back later for updates. \ No newline at end of file +Official Docker images are published to the GitHub Container Registry with every release, for ``linux/amd64`` and ``linux/arm64``: + +.. code-block:: bash + + docker pull ghcr.io/birdnet-team/birdnet-analyzer:latest + +Version tags follow the GitHub releases, so ``2.4.0`` and ``2.4`` point to that release while ``latest`` always points to the most recent one. + +Usage +----- + +The image runs the command line interface. Mount your audio data into the container and pass the usual command line arguments: + +.. code-block:: bash + + # Analyze audio files in the current directory + docker run --rm -v "$PWD:/audio" ghcr.io/birdnet-team/birdnet-analyzer -m birdnet_analyzer.analyze /audio -o /audio/output + +Any of the CLI entry points can be used, e.g. ``-m birdnet_analyzer.species`` or ``-m birdnet_analyzer.segments``. + +Building locally +---------------- + +.. code-block:: bash + + git clone https://github.com/birdnet-team/BirdNET-Analyzer.git + cd BirdNET-Analyzer + docker build -t birdnet-analyzer .