Repository: pupil-labs/pye3d-detector (language: python)

Issue:
Fix pye3d build pipeline



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build-deps.yml b/.github/workflows/build-deps.yml
deleted file mode 100644
index abe34920..00000000
--- a/.github/workflows/build-deps.yml
+++ /dev/null
@@ -1,156 +0,0 @@
-# This is a basic workflow that is manually triggered
-
-name: Build dependencies
-
-on:
-  schedule:
-    # run every 6 days to keep cache alive
-    - cron: "30 7 */6 * *"
-  workflow_dispatch:
-    inputs:
-      should_invalidate_cache_download:
-        description: "Fill to invalidate download cache"
-        default: ""
-        required: false
-      should_invalidate_cache_build:
-        description: "Fill to invalidate build cache"
-        default: ""
-        required: false
-
-env:
-  opencv-download-url: "https://github.com/opencv/opencv/archive/4.2.0.zip"
-  opencv-install-dir: ${{ github.workspace }}/opencv-build
-
-  eigen-download-url: "https://gitlab.com/libeigen/eigen/-/archive/3.3.7/eigen-3.3.7.zip"
-  eigen-install-dir: ${{ github.workspace }}/eigen-build
-
-jobs:
-  # This workflow contains a single job called "greet"
-  build_opencv:
-    strategy:
-      matrix:
-        os: [ubuntu-latest, macos-latest, windows-latest]
-    runs-on: ${{ matrix.os }}
-    continue-on-error: false
-    # Steps represent a sequence of tasks that will be executed as part of the job
-    steps:
-      - name: Cache OpenCV source code download
-        id: opencv-source-cache
-        uses: actions/cache@v2.1.3
-        with:
-          # A list of files, directories, and wildcard patterns to cache and restore
-          path: opencv.zip
-          # An explicit key for restoring and saving the cache
-          key: opencv-source-cache-${{ env.opencv-download-url }}
-      - name: Download on Unix
-        if: (github.event.inputs.should_invalidate_cache_download || steps.opencv-source-cache.outputs.cache-hit != 'true') && !contains(runner.os, 'windows')
-        run: wget -q -O opencv.zip ${{ env.opencv-download-url }}
-      - name: Download on Windows
-        if: (github.event.inputs.should_invalidate_cache_download || steps.opencv-source-cache.outputs.cache-hit != 'true') && contains(runner.os, 'windows')
-        run: Invoke-WebRequest ${{ env.opencv-download-url }} -OutFile opencv.zip
-      - name: Cache OpenCV build
-        id: opencv-build-cache
-        uses: actions/cache@v2.1.3
-        with:
-          path: ${{ env.opencv-install-dir }}
-          key: opencv-build-cache-${{ env.opencv-download-url }}
-      - name: Prepare build
-        shell: bash
-        run: |
-          unzip -q opencv.zip
-          mv opencv-4.* opencv/
-          mkdir opencv/build
-      - name: Configure build
-        if: github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true'
-        shell: bash
-        working-directory: opencv/build
-        run: |
-          cmake .. \
-          -DCMAKE_BUILD_TYPE=Release \
-          -DCMAKE_INSTALL_PREFIX="${{ env.opencv-install-dir }}" \
-          -DBUILD_LIST="core,highgui,videoio,imgcodecs,imgproc,video" \
-          -DBUILD_opencv_world=ON \
-          -DBUILD_EXAMPLES=OFF \
-          -DBUILD_DOCS=OFF \
-          -DBUILD_PERF_TESTS=OFF \
-          -DBUILD_TESTS=OFF \
-          -DBUILD_opencv_java=OFF \
-          -DBUILD_opencv_python=OFF \
-          -DWITH_OPENMP=ON \
-          -DWITH_IPP=ON \
-          -DWITH_CSTRIPES=ON \
-          -DWITH_OPENCL=ON \
-          -DWITH_CUDA=OFF \
-          -DWITH_TBB=OFF \
-          -DWITH_MSMF=OFF
-      - name: Compile on Windows
-        if: runner.os == 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true')
-        run: |
-          cd opencv/build
-          cmake --build . --target INSTALL --config Release --parallel
-      - name: Compile on Unix
-        if: runner.os != 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true')
-        working-directory: opencv/build
-        run: |
-          make
-          make install  # This workflow contains a single job called "greet"
-      - name: Upload build as artifact
-        uses: actions/upload-artifact@v2
-        with:
-          name: opencv-build-${{ matrix.os }}
-          path: ${{ env.opencv-install-dir }}
-  build_eigen:
-    strategy:
-      matrix:
-        os: [ubuntu-latest, macos-latest, windows-latest]
-    runs-on: ${{ matrix.os }}
-    continue-on-error: false
-    # Steps represent a sequence of tasks that will be executed as part of the job
-    steps:
-      - name: Cache Eigen source code download
-        id: eigen-source-cache
-        uses: actions/cache@v2.1.3
-        with:
-          # A list of files, directories, and wildcard patterns to cache and restore
-          path: eigen.zip
-          # An explicit key for restoring and saving the cache
-          key: eigen-source-cache-${{ env.eigen-download-url }}
-      - name: Download on Unix
-        if: (github.event.inputs.should_invalidate_cache_download || steps.eigen-source-cache.outputs.cache-hit != 'true') && !contains(runner.os, 'windows')
-        run: wget -q -O eigen.zip ${{ env.eigen-download-url }}
-      - name: Download on Windows
-        if: (github.event.inputs.should_invalidate_cache_download || steps.eigen-source-cache.outputs.cache-hit != 'true') && contains(runner.os, 'windows')
-        run: Invoke-WebRequest ${{ env.eigen-download-url }} -OutFile eigen.zip
-      - name: Cache eigen build
-        id: eigen-build-cache
-        uses: actions/cache@v2.1.3
-        with:
-          path: ${{ env.eigen-install-dir }}
-          key: eigen-build-cache-${{ env.eigen-download-url }}
-      - name: Prepare build
-        shell: bash
-        run: |
-          set -xe
-          unzip -q eigen.zip
-          mv eigen-3.* eigen/
-          mkdir eigen/build
-      - name: Configure build
-        if: github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true'
-        working-directory: eigen/build
-        shell: bash
-        run: cmake .. -DCMAKE_INSTALL_PREFIX="${{ env.eigen-install-dir }}"
-      - name: Compile on Windows
-        if: runner.os == 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true')
-        working-directory: eigen/build
-        run: cmake --build . --target INSTALL --config Release --parallel
-      - name: Compile on Unix
-        if: runner.os != 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true')
-        working-directory: eigen/build
-        run: |
-          make
-          make install
-      - name: Upload build as artifact
-        uses: actions/upload-artifact@v2
-        with:
-          name: eigen-build-${{ matrix.os }}
-          path: ${{ env.eigen-install-dir }}
diff --git a/.github/workflows/build-pye3d.yml b/.github/workflows/build-pye3d.yml
index 68177856..4a60577a 100644
--- a/.github/workflows/build-pye3d.yml
+++ b/.github/workflows/build-pye3d.yml
@@ -3,99 +3,387 @@
 name: Build pye3d
 
 on:
+  pull_request:
   push:
-    branches: ["master", "ga-build"]
+    branches: ["master"]
+  schedule:
+    # run every 6 days to keep cache alive
+    - cron: "30 7 */6 * *"
   workflow_dispatch:
+    inputs:
+      should_invalidate_cache_download:
+        description: "Fill to invalidate download cache"
+        default: ""
+        required: false
+      should_invalidate_cache_build:
+        description: "Fill to invalidate build cache"
+        default: ""
+        required: false
 
 env:
+  opencv-download-url: "https://github.com/opencv/opencv/archive/4.2.0.zip"
+  eigen-download-url: "https://gitlab.com/libeigen/eigen/-/archive/3.3.7/eigen-3.3.7.zip"
+
   opencv-install-dir: ${{ github.workspace }}/opencv-build
   eigen-install-dir: ${{ github.workspace }}/eigen-build
 
+  opencv-install-dir-manylinux: /pye3d-deps/opencv
+  eigen-install-dir-manylinux: /pye3d-deps/eigen
+
+  pye3d-test-input-download-url: "https://github.com/pupil-labs/pye3d-detector/wiki/files/pye3d_test_input.npz"
+
 jobs:
-  build_pye3d:
-    name: "pye3d (py${{ matrix.python-version }} on ${{ matrix.os }})"
+  build_opencv_manylinux:
+    name: "Build OpenCV on manylinux"
     strategy:
       matrix:
-        os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.6", "3.7", "3.8"]
-    continue-on-error: true
+        os: [ubuntu-latest]
     runs-on: ${{ matrix.os }}
+    container:
+      image: quay.io/pypa/manylinux2014_x86_64
+    env:
+      opencv-install-dir-manylinux: /pye3d-deps/opencv
+
     steps:
-      # DEPENDENCIES
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
+      - name: Cache OpenCV source code download
+        id: opencv-source-cache
+        uses: actions/cache@v2.1.3
         with:
-          python-version: ${{ matrix.python-version }}
-
-      - id: pip-chache-dir-lookup
+          path: opencv.zip
+          key: opencv-source-cache-${{ env.opencv-download-url }}
+      - name: Download on manylinux
+        if: (github.event.inputs.should_invalidate_cache_download || steps.opencv-source-cache.outputs.cache-hit != 'true')
+        run: curl -L ${{ env.opencv-download-url }} --output opencv.zip --silent
+      - name: Cache OpenCV build
+        id: opencv-build-cache
+        uses: actions/cache@v2.1.3
+        with:
+          path: ${{ env.opencv-install-dir-manylinux }}
+          key: opencv-build-cache-${{ matrix.os }}-${{ env.opencv-download-url }}
+      - name: Prepare build
         shell: bash
         run: |
-          CACHE_DIR=`python -m pip cache dir`
-          echo "Found pip cache location at $CACHE_DIR"
-          echo "::set-output name=pip_cache_dir::${CACHE_DIR}"
-      - name: Cache pip
-        uses: actions/cache@v2
-        with:
-          path: ${{ steps.pip-chache-dir-lookup.outputs.pip_cache_dir }}
-          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
-          restore-keys: |
-            ${{ runner.os }}-pip-
-            ${{ runner.os }}-
-      - name: Load OpenCV build cache
+          unzip -q opencv.zip
+          mv opencv-4.* opencv/
+          mkdir opencv/build
+      - name: Configure build
+        if: github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true'
+        shell: bash
+        working-directory: opencv/build
+        run: |
+          cmake .. \
+          -DCMAKE_BUILD_TYPE=Release \
+          -DCMAKE_INSTALL_PREFIX="${{ env.opencv-install-dir-manylinux }}" \
+          -DBUILD_LIST="core,highgui,videoio,imgcodecs,imgproc,video" \
+          -DBUILD_opencv_world=ON \
+          -DBUILD_EXAMPLES=OFF \
+          -DBUILD_DOCS=OFF \
+          -DBUILD_PERF_TESTS=OFF \
+          -DBUILD_TESTS=OFF \
+          -DBUILD_opencv_java=OFF \
+          -DBUILD_opencv_python=OFF \
+          -DWITH_OPENMP=ON \
+          -DWITH_IPP=ON \
+          -DWITH_CSTRIPES=ON \
+          -DWITH_OPENCL=ON \
+          -DWITH_CUDA=OFF \
+          -DWITH_TBB=OFF \
+          -DWITH_MSMF=OFF
+      - name: Compile on manylinux
+        if: (github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true')
+        working-directory: opencv/build
+        run: |
+          make
+          make install
+      - name: Upload build as artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: build-${{ matrix.os }}
+          path: ${{ env.opencv-install-dir-manylinux }}
+
+  build_opencv_macos_windows:
+    name: Build OpenCV on ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    continue-on-error: false
+    steps:
+      - name: Cache OpenCV source code download
+        id: opencv-source-cache
+        uses: actions/cache@v2.1.3
+        with:
+          path: opencv.zip
+          key: opencv-source-cache-${{ env.opencv-download-url }}
+      - name: Download on macOS
+        if: (github.event.inputs.should_invalidate_cache_download || steps.opencv-source-cache.outputs.cache-hit != 'true') && !contains(runner.os, 'windows')
+        run: wget -q -O opencv.zip ${{ env.opencv-download-url }}
+      - name: Download on Windows
+        if: (github.event.inputs.should_invalidate_cache_download || steps.opencv-source-cache.outputs.cache-hit != 'true') && contains(runner.os, 'windows')
+        run: Invoke-WebRequest ${{ env.opencv-download-url }} -OutFile opencv.zip
+      - name: Cache OpenCV build
         id: opencv-build-cache
         uses: actions/cache@v2.1.3
         with:
           path: ${{ env.opencv-install-dir }}
-          key: opencv-build-cache-${{ env.opencv-download-url }}
-      - name: Load eigen build cache
+          key: opencv-build-cache-${{ matrix.os }}-${{ env.opencv-download-url }}
+      - name: Prepare build
+        shell: bash
+        run: |
+          unzip -q opencv.zip
+          mv opencv-4.* opencv/
+          mkdir opencv/build
+      - name: Configure build
+        if: github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true'
+        shell: bash
+        working-directory: opencv/build
+        run: |
+          cmake .. \
+          -DCMAKE_BUILD_TYPE=Release \
+          -DCMAKE_INSTALL_PREFIX="${{ env.opencv-install-dir }}" \
+          -DBUILD_LIST="core,highgui,videoio,imgcodecs,imgproc,video" \
+          -DBUILD_opencv_world=ON \
+          -DBUILD_EXAMPLES=OFF \
+          -DBUILD_DOCS=OFF \
+          -DBUILD_PERF_TESTS=OFF \
+          -DBUILD_TESTS=OFF \
+          -DBUILD_opencv_java=OFF \
+          -DBUILD_opencv_python=OFF \
+          -DWITH_OPENMP=ON \
+          -DWITH_IPP=ON \
+          -DWITH_CSTRIPES=ON \
+          -DWITH_OPENCL=ON \
+          -DWITH_CUDA=OFF \
+          -DWITH_TBB=OFF \
+          -DWITH_MSMF=OFF
+      - name: Compile on Windows
+        if: runner.os == 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true')
+        run: |
+          cd opencv/build
+          cmake --build . --target INSTALL --config Release --parallel
+      - name: Compile on macOS
+        if: runner.os != 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.opencv-build-cache.outputs.cache-hit != 'true')
+        working-directory: opencv/build
+        run: |
+          make -j
+          make install
+      - name: Upload build as artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: build-${{ matrix.os }}
+          path: ${{ env.opencv-install-dir }}
+
+  build_eigen_manylinux:
+    name: Build Eigen on manylinux
+    strategy:
+      matrix:
+        os: [ubuntu-latest]
+    runs-on: ${{ matrix.os }}
+    container:
+      image: quay.io/pypa/manylinux2014_x86_64
+    env:
+      eigen-install-dir-manylinux: /pye3d-deps/eigen
+    steps:
+      - name: Cache Eigen source code download
+        id: eigen-source-cache
+        uses: actions/cache@v2.1.3
+        with:
+          path: eigen.zip
+          key: eigen-source-cache-${{ env.eigen-download-url }}
+      - name: Download on manylinux
+        if: (github.event.inputs.should_invalidate_cache_download || steps.eigen-source-cache.outputs.cache-hit != 'true')
+        run: curl -L ${{ env.eigen-download-url }} --output eigen.zip --silent
+      - name: Cache eigen build
+        id: eigen-build-cache
+        uses: actions/cache@v2.1.3
+        with:
+          path: ${{ env.eigen-install-dir-manylinux }}
+          key: eigen-build-cache-${{ matrix.os }}-${{ env.eigen-download-url }}
+      - name: Prepare build
+        shell: bash
+        run: |
+          set -xe
+          unzip -q eigen.zip
+          mv eigen-3.* eigen/
+          mkdir eigen/build
+      - name: Configure build
+        if: github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true'
+        working-directory: eigen/build
+        shell: bash
+        run: cmake .. -DCMAKE_INSTALL_PREFIX="${{ env.eigen-install-dir-manylinux }}"
+      - name: Compile on manylinux
+        if: (github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true')
+        working-directory: eigen/build
+        run: |
+          make
+          make install
+      - name: Upload build as artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: build-${{ matrix.os }}
+          path: ${{ env.eigen-install-dir-manylinux }}
+
+  build_eigen_macos_windows:
+    name: Build Eigen on ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    continue-on-error: false
+    steps:
+      - name: Cache Eigen source code download
+        id: eigen-source-cache
+        uses: actions/cache@v2.1.3
+        with:
+          path: eigen.zip
+          key: eigen-source-cache-${{ env.eigen-download-url }}
+      - name: Download on Unix
+        if: (github.event.inputs.should_invalidate_cache_download || steps.eigen-source-cache.outputs.cache-hit != 'true') && !contains(runner.os, 'windows')
+        run: wget -q -O eigen.zip ${{ env.eigen-download-url }}
+      - name: Download on Windows
+        if: (github.event.inputs.should_invalidate_cache_download || steps.eigen-source-cache.outputs.cache-hit != 'true') && contains(runner.os, 'windows')
+        run: Invoke-WebRequest ${{ env.eigen-download-url }} -OutFile eigen.zip
+      - name: Cache eigen build
         id: eigen-build-cache
         uses: actions/cache@v2.1.3
         with:
           path: ${{ env.eigen-install-dir }}
-          key: eigen-build-cache-${{ env.eigen-download-url }}
-      - name: "Install dependencies"
+          key: eigen-build-cache-${{ matrix.os }}-${{ env.eigen-download-url }}
+      - name: Prepare build
         shell: bash
         run: |
           set -xe
-          python -m site
-          python -m pip install --upgrade pip setuptools wheel build pep517
-          python -m pip install --upgrade virtualenv tox tox-gh-actions
-      # BUILD DIST
-      - name: "Build pye3d wheel"
+          unzip -q eigen.zip
+          mv eigen-3.* eigen/
+          mkdir eigen/build
+      - name: Configure build
+        if: github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true'
+        working-directory: eigen/build
+        shell: bash
+        run: cmake .. -DCMAKE_INSTALL_PREFIX="${{ env.eigen-install-dir }}"
+      - name: Compile on Windows
+        if: runner.os == 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true')
+        working-directory: eigen/build
+        run: cmake --build . --target INSTALL --config Release --parallel
+      - name: Compile on macOS
+        if: runner.os != 'Windows' && (github.event.inputs.should_invalidate_cache_build || steps.eigen-build-cache.outputs.cache-hit != 'true')
+        working-directory: eigen/build
+        run: |
+          make
+          make install
+      - name: Upload build as artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: build-${{ matrix.os }}
+          path: ${{ env.eigen-install-dir }}
+
+  build_wheels:
+    name: "Build pye3d wheels on ${{ matrix.os }}"
+    if: github.event_name != 'schedule'
+    needs:
+      - build_opencv_macos_windows
+      - build_opencv_manylinux
+      - build_eigen_macos_windows
+      - build_eigen_manylinux
+    strategy:
+      matrix:
+        os: [windows-latest, ubuntu-latest, macos-latest]
+    continue-on-error: true
+    runs-on: ${{ matrix.os }}
+    steps:
+      - name: Dump GitHub context
         env:
-          OpenCV_DIR: ${{ env.opencv-install-dir }}
-          Eigen3_DIR: ${{ env.eigen-install-dir }}
-        #       run: python -m build -s -w .
-        run: python -m pip wheel . -w dist --no-deps
-      - name: Delocate dynamic libs on macOS
+          GITHUB_CONTEXT: ${{ toJSON(github) }}
+        run: echo "$GITHUB_CONTEXT"
+      - uses: actions/checkout@v2
+      - name: Download build as artifact
+        uses: actions/download-artifact@v2
+        with:
+          name: build-${{ matrix.os }}
+      - name: Patch OpenCV install_name on macOS
         if: runner.os == 'macOS'
-        run: |
-          set -xe
-          python -m pip install delocate
-          mv dist/ raw_wheels/
-          delocate-wheel -w dist -v raw_wheels/*.whl
-      - name: "Build pye3d sdist"
-        run: python -m pep517.build --source .
-      # TEST WHEELS
-      - name: "Run tox targets for ${{ matrix.python-version }}"
-        # use bash for wildcard pattern
+        working-directory: lib
+        run: | # replace @rpath in install_name with absolute path
+          for dylib in libopencv_world*.dylib
+          do
+            INSTALL_NAME_BEFORE=$(otool -D $dylib | grep --color=never @rpath)
+            INSTALL_NAME_AFTER=${INSTALL_NAME_BEFORE/@rpath/$(pwd)}
+            echo "Changing $dylib install_name from $INSTALL_NAME_BEFORE to $INSTALL_NAME_AFTER"
+            install_name_tool -id "$INSTALL_NAME_AFTER" $dylib
+          done
+      - name: Verify patched OpenCV install_name on macOS
+        if: runner.os == 'macOS'
+        working-directory: lib
+        run: otool -D libopencv_world*.dylib
+      - name: Dump file list
         shell: bash
-        run: python -m tox --installpkg ./dist/*.whl
+        run: ls -laR
+
+      - name: Build wheels
+        uses: pypa/cibuildwheel@v1.11.1.post1
+        env:
+          OpenCV_DIR: ${{ github.workspace }}
+          Eigen3_DIR: ${{ github.workspace }}
+          CIBW_ENVIRONMENT_LINUX: >
+            LD_LIBRARY_PATH=/project/lib64:$LD_LIBRARY_PATH
+            OpenCV_DIR=/project Eigen3_DIR=/project
+
+          CIBW_SKIP: "{cp,pp}27-* {cp,pp}35-* pp* *win32 *_aarch64 *_ppc64le *_s390x *_i686"
+          CIBW_ARCHS_MACOS: "x86_64"
+          CIBW_ARCHS_LINUX: "x86_64"
+
+          CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
+          CIBW_BEFORE_BUILD_WINDOWS: pip install delvewheel
+          CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: python scripts\repair_wheels_windows.py {wheel} {dest_dir}
+
+          CIBW_TEST_REQUIRES: pytest opencv-python pandas scikit-image
+          CIBW_TEST_COMMAND: >
+            curl -L ${{ env.pye3d-test-input-download-url }} --silent
+            --output {package}/tests/integration/input/pye3d_test_input.npz
+            && pytest {package}/tests
+          CIBW_TEST_COMMAND_WINDOWS: >
+            curl -L ${{ env.pye3d-test-input-download-url }} --silent
+            --output {package}\tests\integration\input\pye3d_test_input.npz
+            && pytest {package}\tests
+          CIBW_TEST_SKIP: "*-macosx_arm64 *-macosx_universal2:arm64"
+
       - uses: actions/upload-artifact@v2
+        if: always()
         with:
-          name: pye3d-dist-${{ matrix.os }}-${{ matrix.python-version }}
-          path: ./dist/*
-      # ON FAILURE
-      - name: List intermediate files on failure
-        if: ${{ failure() }}
-        shell: bash
-        run: ls -lR
-      - name: Upload logs on failure
-        if: ${{ failure() }}
+          name: distribution
+          path: ./wheelhouse/*.whl
+
+  build_sdist:
+    name: Build source distribution
+    if: github.event_name != 'schedule'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v1
+        with:
+          python-version: 3.7
+      - name: Build source package
+        run: |
+          pip install build
+          python -m build --sdist .
+      - name: Upload source package
         uses: actions/upload-artifact@v2
         with:
-          name: pye3d-build-log-${{ matrix.os }}-${{ matrix.python-version }}
-          path: |
-            ./_skbuild/
-            ./dist/
-            ./raw_wheels/
+          name: distribution
+          path: dist/
+
+  publish:
+    runs-on: ubuntu-latest
+    needs: [build_wheels, build_sdist]
+    if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/')
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/download-artifact@v2
+        with:
+          name: distribution
+          path: dist/
+      - name: Publish to PyPI
+        uses: pypa/gh-action-pypi-publish@master
+        with:
+          user: __token__
+          password: ${{ secrets.PYPI_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c10734f2..465f3e45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -155,6 +155,7 @@ PyGL/*
 *.png
 *.npy
 *.npz
+*.csv
  
 .idea/*
  
@@ -179,4 +180,39 @@ _skbuild
 
 # benchmarking
 __benchmark_data__
-benchmarks/
\ No newline at end of file
+benchmarks/
+
+
+# Created by https://www.toptal.com/developers/gitignore/api/macos
+# Edit at https://www.toptal.com/developers/gitignore?templates=macos
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# End of https://www.toptal.com/developers/gitignore/api/macos
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63c76600..f7f131e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
 # Changelog
 
+## 0.0.8 (2021-06-17)
+- Automated Python wheels for Python 3.6 - 3.9 on Linux, macOS, and Windows
+
 ## 0.0.7 (2021-05-11)
 - Simplification of `Conic` parameter calculation - [#26](https://github.com/pupil-labs/pye3d-detector/pull/26)
 - Incremental performance improvements - [#27](https://github.com/pupil-labs/pye3d-detector/pull/27)
diff --git a/scripts/repair_wheels_windows.py b/scripts/repair_wheels_windows.py
new file mode 100644
index 00000000..e512f967
--- /dev/null
+++ b/scripts/repair_wheels_windows.py
@@ -0,0 +1,30 @@
+import argparse
+import pathlib
+import shutil
+import subprocess
+
+
+def repair(wheel, dest_dir):
+    cmd = "delvewheel.exe repair -w {dest_dir} {wheel}"
+    cmd = cmd.format(wheel=wheel, dest_dir=dest_dir)
+    out = subprocess.check_output(cmd, shell=True).decode()
+    print("+ " + cmd)
+    print("+ delvewheel.exe output:\n" + out)
+    last_line = out.splitlines()[-1]
+
+    # cibuildwheels expects the wheel to be in dest_dir but delvewheel does not copy the
+    # wheel to dest_dir if there is nothing to repair
+    if last_line.startswith("no external dependencies are needed"):
+        print(f"+ Manually copying {wheel} to {dest_dir}")
+        pathlib.Path(dest_dir).mkdir(exist_ok=True)
+        shutil.copy2(wheel, dest_dir)
+    else:
+        print(f"+ No need for a manual copy")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("wheel")
+    parser.add_argument("dest_dir")
+    args = parser.parse_args()
+    repair(args.wheel, args.dest_dir)
diff --git a/update_models.py b/scripts/update_models.py
similarity index 100%
rename from update_models.py
rename to scripts/update_models.py
diff --git a/setup.py b/setup.py
index a34c38c7..26ae6b9f 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@
     "sortedcontainers",
 ]
 extras_require = {
-    "dev": ["pytest", "tox", "bump2version"],
+    "dev": ["pytest", "tox", "bump2version", "pandas", "scikit-image"],
     "with-opencv": ["opencv-python"],
 }
 
@@ -44,7 +44,7 @@
 
 setup(
     author="Pupil Labs",
-    author_email="pypa@pupil-labs.com",
+    author_email="info@pupil-labs.com",
     classifiers=[
         "Development Status :: 3 - Alpha",
         "Intended Audience :: Developers",
@@ -66,9 +66,14 @@
     install_requires=requirements,
     long_description=long_description,
     long_description_content_type="text/markdown",
+    project_urls={
+        "Changelog": "https://github.com/pupil-labs/pye3d-detector/blob/master/CHANGELOG.md",
+        "Pupil Core Documentation": "https://docs.pupil-labs.com/core/",
+        "Pupil Labs Homepage": "https://pupil-labs.com/",
+    },
     name=package,
     package_data={package: ["refraction_models/*.save"]},
     packages=find_packages(),
-    url="",
+    url="https://github.com/pupil-labs/pye3d-detector/",
     version="0.0.7",
 )
diff --git a/tox.ini b/tox.ini
index 1f16627d..6b50c0c9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py36, py37, py38
+envlist = py36, py37, py38, py39
 skip_missing_interpreters = true
 isolated_build = true
 
@@ -8,6 +8,9 @@ changedir = tests
 deps =
     pytest
     opencv-python-headless
+    scikit-image
+    pandas
+    matplotlib
 commands =
     python -m pip install -U pip
     pytest --basetemp={envtmpdir}
@@ -17,3 +20,4 @@ python =
     3.6: py36
     3.7: py37
     3.8: py38
+    3.9: py39


Test patch:
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/input/.gitkeep b/tests/integration/input/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/output/.gitkeep b/tests/integration/output/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/test_synthetic_metrics.py b/tests/integration/test_synthetic_metrics.py
new file mode 100644
index 00000000..423004f9
--- /dev/null
+++ b/tests/integration/test_synthetic_metrics.py
@@ -0,0 +1,495 @@
+from pye3d.detector_3d import CameraModel
+from pye3d.detector_3d import Detector3D as Pye3D
+from pye3d.detector_3d import DetectorMode
+
+import math
+import numpy as np
+import pandas as pd
+import skimage.measure as skmeas
+
+import pytest
+from .utils import abs_diff, input_dir, output_dir, remove_file
+
+
+# Define all input files
+INPUT_PATH = input_dir().joinpath("pye3d_test_input.npz")
+
+# Define all output files
+OUTPUT_GENERATED_RESULTS_CSV_PATH = output_dir().joinpath(
+    "pye3d_test_generated_results.csv"
+)
+OUTPUT_PUPIL_RADIUS_PLOT_PATH = output_dir().joinpath(
+    "pye3d_test_pupil_radius_plot.png"
+)
+OUTPUT_PUPIL_RADIUS_ERROR_PLOT_PATH = output_dir().joinpath(
+    "pye3d_test_pupil_radius_error_plot.png"
+)
+OUTPUT_GAZE_ANGLE_PHI_PLOT_PATH = output_dir().joinpath(
+    "pye3d_test_gaze_angle_phi_plot.png"
+)
+OUTPUT_GAZE_ANGLE_THETA_PLOT_PATH = output_dir().joinpath(
+    "pye3d_test_gaze_angle_theta_plot.png"
+)
+OUTPUT_EYE_CENTER_3D_PLOT_PATH = output_dir().joinpath(
+    "pye3d_test_eye_center_3d_error_plot.png"
+)
+
+# Static detector properties
+DETECTOR_EXPECTED_CONVERGENCE_MAX_TIME = 2.14
+PUPIL_RADIUS_EPS = 0.057
+DATUM_COMPONENT_PHI_EPS = 1.010
+DATUM_COMPONENT_THETA_EPS = 0.456
+EYE_CENTER_3D_EPS = 0.5
+GAZE_ANGLE_EPS = 1.022
+
+
+def test_convergence_time(convergence_time):
+    assert (
+        convergence_time <= DETECTOR_EXPECTED_CONVERGENCE_MAX_TIME
+    )  # TODO: Add description
+
+
+def test_datum_component_phi(dataset, convergence_time):
+    gt_df, gr_df = dataset
+
+    gaze_angle_phi_gt = gt_df["phi"].values * 180.0 / np.pi
+    gaze_angle_phi_gr = gr_df["phi"].values * 180.0 / np.pi
+
+    gaze_angle_phi_error = abs_diff(gaze_angle_phi_gt, gaze_angle_phi_gr)
+
+    gaze_angle_phi_gr_mean = gr_df["phi"].mean() * 180.0 / np.pi
+
+    save_plot(
+        # Generated Results
+        a_label="pye3d",
+        ax=gr_df["timestamp"],
+        ay=gaze_angle_phi_gr,
+        # Ground Truth
+        b_label="ground truth",
+        bx=gt_df["timestamp"],
+        by=gaze_angle_phi_gt,
+        # Legend
+        figsize=(10, 4),
+        title="phi\n",
+        xlabel="time [s]",
+        ylabel="[°]",
+        ylim=(gaze_angle_phi_gr_mean - 55, gaze_angle_phi_gr_mean + 55),
+        v_threshold=convergence_time,
+        # Image Path
+        path=OUTPUT_GAZE_ANGLE_PHI_PLOT_PATH,
+    )
+
+    gaze_angle_phi_error = gaze_angle_phi_error[gr_df["timestamp"] > convergence_time]
+
+    assert np.all(
+        gaze_angle_phi_error <= DATUM_COMPONENT_PHI_EPS
+    )  # TODO: Add description
+
+
+def test_datum_component_theta(dataset, convergence_time):
+    gt_df, gr_df = dataset
+
+    gaze_angle_theta_gt = gt_df["theta"].values * 180.0 / np.pi
+    gaze_angle_theta_gr = gr_df["theta"].values * 180.0 / np.pi
+
+    gaze_angle_theta_error = abs_diff(gaze_angle_theta_gt, gaze_angle_theta_gr)
+
+    gaze_angle_theta_gr_mean = gr_df["theta"].mean() * 180.0 / np.pi
+
+    save_plot(
+        # Generated Results
+        a_label="pye3d",
+        ax=gr_df["timestamp"],
+        ay=gaze_angle_theta_gr,
+        # Ground Truth
+        b_label="ground truth",
+        bx=gt_df["timestamp"],
+        by=gaze_angle_theta_gt,
+        # Legend
+        figsize=(10, 4),
+        title="theta\n",
+        xlabel="time [s]",
+        ylabel="[°]",
+        ylim=(gaze_angle_theta_gr_mean - 55, gaze_angle_theta_gr_mean + 55),
+        v_threshold=convergence_time,
+        # Image Path
+        path=OUTPUT_GAZE_ANGLE_THETA_PLOT_PATH,
+    )
+
+    gaze_angle_theta_error = gaze_angle_theta_error[
+        gr_df["timestamp"] > convergence_time
+    ]
+
+    assert np.all(
+        gaze_angle_theta_error <= DATUM_COMPONENT_THETA_EPS
+    )  # TODO: Add description
+
+
+def test_pupil_radius(dataset, convergence_time):
+    gt_df, gr_df = dataset
+
+    pupil_radius_gt = gt_df["circle_3d_radius"]
+    pupil_radius_gr = gr_df["circle_3d_radius"]
+
+    pupil_radius_error = abs_diff(pupil_radius_gt, pupil_radius_gr)
+
+    save_plot(
+        # Generated Results
+        a_label="pye3d",
+        ax=gr_df["timestamp"],
+        ay=pupil_radius_gr,
+        # Ground Truth
+        b_label="ground truth",
+        bx=gt_df["timestamp"],
+        by=pupil_radius_gt,
+        # Legend
+        figsize=(10, 4),
+        title="pupil radius\n",
+        xlabel="time [s]",
+        ylabel="[mm]",
+        ylim=(0, 5),
+        v_threshold=convergence_time,
+        # Image Path
+        path=OUTPUT_PUPIL_RADIUS_PLOT_PATH,
+    )
+
+    save_plot(
+        ax=gr_df["timestamp"],
+        ay=pupil_radius_error,
+        a_color="r",
+        # Legend
+        figsize=(10, 4),
+        title="pupil radius error\n",
+        xlabel="time [s]",
+        ylabel="[mm]",
+        ylim=(0, 1),
+        h_threshold=PUPIL_RADIUS_EPS,
+        v_threshold=convergence_time,
+        # Image Path
+        path=OUTPUT_PUPIL_RADIUS_ERROR_PLOT_PATH,
+    )
+
+    pupil_radius_error = pupil_radius_error[gr_df["timestamp"] > convergence_time]
+
+    assert np.all(pupil_radius_error <= PUPIL_RADIUS_EPS)  # TODO: Add description
+
+
+def test_eye_center_3d(dataset, convergence_time, eye_center_3d_errors):
+    gt_df, gr_df = dataset
+
+    save_plot(
+        ax=gr_df["timestamp"],
+        ay=eye_center_3d_errors,
+        a_color="r",
+        # Legend
+        figsize=(10, 4),
+        title="eye center 3d error\n",
+        xlabel="time [s]",
+        ylabel="[mm]",
+        ylim=(0, 5),
+        h_threshold=EYE_CENTER_3D_EPS,
+        v_threshold=convergence_time,
+        # Image Path
+        path=OUTPUT_EYE_CENTER_3D_PLOT_PATH,
+    )
+
+    eye_center_3d_errors = eye_center_3d_errors[gr_df["timestamp"] > convergence_time]
+
+    assert np.all(eye_center_3d_errors <= EYE_CENTER_3D_EPS)  # TODO: Add description
+
+
+def test_gaze_angle(dataset, convergence_time):
+    gt_df, gr_df = dataset
+
+    gaze_angle_columns = [
+        "circle_3d_normal_x",
+        "circle_3d_normal_y",
+        "circle_3d_normal_z",
+    ]
+
+    # cosine distance; no need to calculate norms, as input already has length 1
+    dot_product = (gr_df[gaze_angle_columns] * gt_df[gaze_angle_columns]).sum(axis=1)
+    gaze_angle_error = np.rad2deg(np.arccos(dot_product))
+
+    save_plot(
+        ax=gr_df["timestamp"],
+        ay=gaze_angle_error,
+        a_color="r",
+        # Legend
+        figsize=(10, 4),
+        title="eye center 3d error\n",
+        xlabel="time [s]",
+        ylabel="[mm]",
+        ylim=(0, 5),
+        h_threshold=GAZE_ANGLE_EPS,
+        v_threshold=convergence_time,
+        # Image Path
+        path=OUTPUT_EYE_CENTER_3D_PLOT_PATH,
+    )
+
+    gaze_angle_error = gaze_angle_error[gr_df["timestamp"] > convergence_time]
+
+    assert np.all(gaze_angle_error <= GAZE_ANGLE_EPS)  # TODO: Add description
+
+
+@pytest.fixture(scope="module")
+def convergence_time(dataset, eye_center_3d_errors):
+    gt_df, gr_df = dataset
+
+    eye_center_3d_convergence = eye_center_3d_errors > EYE_CENTER_3D_EPS
+    convergence_index = -np.argwhere(eye_center_3d_convergence[::-1])[0][0] - 1
+    convergence_time = gt_df.timestamp.iloc[convergence_index]
+
+    return convergence_time
+
+
+@pytest.fixture(scope="module")
+def eye_center_3d_errors(dataset):
+    gt_df, gr_df = dataset
+
+    columns = ["sphere_center_x", "sphere_center_y", "sphere_center_z"]
+    errors = np.linalg.norm(gr_df[columns].values - gt_df[columns].values, axis=1)
+
+    return errors
+
+
+@pytest.fixture(scope="module")
+def dataset():
+
+    # Check all input files exist
+    assert INPUT_PATH.is_file(), f"Missing test input file: {INPUT_PATH}"
+
+    # Cleanup output files from previous runs
+    remove_file(OUTPUT_GENERATED_RESULTS_CSV_PATH)
+    remove_file(OUTPUT_PUPIL_RADIUS_PLOT_PATH)
+    remove_file(OUTPUT_PUPIL_RADIUS_ERROR_PLOT_PATH)
+    remove_file(OUTPUT_GAZE_ANGLE_PHI_PLOT_PATH)
+    remove_file(OUTPUT_GAZE_ANGLE_THETA_PLOT_PATH)
+    remove_file(OUTPUT_EYE_CENTER_3D_PLOT_PATH)
+
+    input_data = np.load(INPUT_PATH)
+
+    image_key = "eye_images"
+    error_msg = f"`{image_key}` not in available keys: {list(input_data.keys())}"
+    assert image_key in input_data, error_msg
+    images = input_data[image_key]
+    FPS = 200.0
+
+    detector = create_detector()
+
+    measured_data = []
+
+    for i, img in enumerate(images):
+
+        pupil_datum, _ = pupil_datum_from_raytraced_image(img=img)
+        pupil_datum["timestamp"] = i / FPS
+
+        result = detector.update_and_detect(pupil_datum, img, debug=True)
+
+        measured_data.append(
+            np.hstack(
+                [
+                    result["timestamp"],
+                    np.array(result["sphere"]["center"]),
+                    result["sphere"]["radius"],
+                    np.array(result["projected_sphere"]["center"]),
+                    np.array(result["projected_sphere"]["axes"]),
+                    result["projected_sphere"]["angle"],
+                    np.array(result["circle_3d"]["center"]),
+                    np.array(result["circle_3d"]["normal"]),
+                    result["circle_3d"]["radius"],
+                    result["diameter_3d"],
+                    pupil_datum["ellipse"]["center"][0],
+                    pupil_datum["ellipse"]["center"][1],
+                    pupil_datum["ellipse"]["axes"][0],
+                    pupil_datum["ellipse"]["axes"][1],
+                    pupil_datum["ellipse"]["angle"],
+                    pupil_datum["ellipse"]["center"][0],
+                    pupil_datum["ellipse"]["center"][1],
+                    pupil_datum["ellipse"]["axes"][1],
+                    result["confidence"],
+                    result["model_confidence"],
+                    result["theta"],
+                    result["phi"],
+                ]
+            )
+        )
+
+    # Load data frame for Ground Truth (GT) from input dir
+    gt_key = "ground_truth"
+    error_msg = f"`{gt_key}` not in available keys: {list(input_data.keys())}"
+    assert gt_key in input_data, error_msg
+    gt_df = input_data[gt_key]
+    gt_df = pd.DataFrame.from_records(gt_df)
+
+    # Create data frame for Generated Results (GR) and save to output dir
+    gr_df = pd.DataFrame(
+        np.concatenate(measured_data, axis=0).reshape(-1, len(COLUMNS_MEASURED)),
+        columns=COLUMNS_MEASURED,
+    )
+    gr_df.to_csv(str(OUTPUT_GENERATED_RESULTS_CSV_PATH))
+
+    # Sanity check: timestamps must match completely
+
+    timestamp_gt = gt_df["timestamp"]
+    timestamp_gr = gr_df["timestamp"]
+
+    assert np.all(abs_diff(timestamp_gt, timestamp_gr) == 0.0)
+
+    return gt_df, gr_df
+
+
+def create_detector():
+    camera = CameraModel(focal_length=561.5, resolution=np.array([400, 400]))
+    detector = Pye3D(camera=camera, long_term_mode=DetectorMode.blocking)
+    detector.reset()
+    return detector
+
+
+COLUMNS_MEASURED = [
+    "timestamp",
+    "sphere_center_x",
+    "sphere_center_y",
+    "sphere_center_z",
+    "sphere_radius",
+    "projected_sphere_center_x",
+    "projected_sphere_center_y",
+    "projected_sphere_minor_axis",
+    "projected_sphere_major_axis",
+    "projected_sphere_angle",
+    "circle_3d_center_x",
+    "circle_3d_center_y",
+    "circle_3d_center_z",
+    "circle_3d_normal_x",
+    "circle_3d_normal_y",
+    "circle_3d_normal_z",
+    "circle_3d_radius",
+    "diameter_3d",
+    "ellipse_center_x",
+    "ellipse_center_y",
+    "ellipse_minor_axis",
+    "ellipse_major_axis",
+    "ellipse_angle",
+    "location_x",
+    "location_y",
+    "diameter",
+    "confidence",
+    "model_confidence",
+    "theta",
+    "phi",
+]
+
+
+def pupil_datum_from_raytraced_image(img=None, raytracer=None, device="cuda"):
+
+    if img is None:
+        img = raytracer.ray_trace_image(device=device)
+
+    if img.ndim == 3:
+        img = img[:, :, 0]
+
+    hard_segmentation = img
+    pupil_label = 10
+
+    pupil_datum = {}
+    pupil_datum["ellipse"] = {}
+    pupil_datum["ellipse"]["axes"] = np.array([0.0, 0.0])
+    pupil_datum["ellipse"]["angle"] = -90.0
+    pupil_datum["ellipse"]["center"] = np.array([0.0, 0.0])
+    pupil_datum["confidence"] = 0.0
+
+    segmentation_pupil = np.zeros(hard_segmentation.shape).astype(np.uint8)
+    segmentation_pupil[hard_segmentation == pupil_label] = pupil_label
+
+    # ellipse fitting based on pupil region props:
+
+    label_image, n_areas = skmeas.label(
+        segmentation_pupil, return_num=True, connectivity=1
+    )
+    properties = skmeas.regionprops(label_image)
+    if len(properties) >= 1:
+        properties = properties[0]
+    if properties:
+        # turn props into dictionary
+        minor_axis_length = properties.minor_axis_length
+        major_axis_length = properties.major_axis_length
+        centroid = properties.centroid
+        angle = properties.orientation
+        props = {}
+        props["minor_axis_length"] = minor_axis_length
+        props["major_axis_length"] = major_axis_length
+        props["centroid"] = centroid  # centroid = (row, column)
+        props["orientation"] = (
+            angle - np.pi / 2.0
+        )  # -pi/2 due to version change of regionprops
+
+    if props:
+
+        # adapt output of regionprops to construct pupil datum analoguous to Pupil 2D Detector:
+        pupil_datum["ellipse"]["axes"] = np.array(
+            [props["minor_axis_length"], props["major_axis_length"]]
+        )
+        angle = props["orientation"] * 180.0 / math.pi
+        pupil_datum["ellipse"]["angle"] = 90 - angle
+        # see cv2.ellipse:
+        # first coord = column/horizontal (from left to right)
+        # second coord = row/vertical (from top to bottom)
+        pupil_datum["ellipse"]["center"] = np.array(
+            [props["centroid"][1], props["centroid"][0]]
+        )
+
+        pupil_datum["confidence"] = 1.0
+
+    return pupil_datum, img
+
+
+def save_plot(
+    title,
+    path,
+    ylim,
+    ax,
+    ay,
+    a_label="",
+    bx=None,
+    by=None,
+    b_label="",
+    a_color="b",
+    b_color="g",
+    h_threshold=None,
+    v_threshold=None,
+    figsize=(10, 4),
+    xlabel="",
+    ylabel="",
+):
+    import matplotlib
+    import matplotlib.pyplot as plt
+
+    fig, axis = plt.subplots(figsize=figsize)
+
+    axis.plot(ax, ay, a_color, alpha=1, label=a_label)
+
+    if bx is not None and by is not None:
+        axis.plot(bx, by, b_color, alpha=1, label=b_label)
+
+    if h_threshold:
+        xlim_lo = min(
+            map(lambda x: x.iloc[0], filter(lambda x: x is not None, [ax, bx]))
+        )
+        xlim_hi = max(
+            map(lambda x: x.iloc[-1], filter(lambda x: x is not None, [ax, bx]))
+        )
+        xlim = xlim_lo, xlim_hi
+        axis.hlines([h_threshold], *xlim, colors="C2", linestyles="dashed")
+
+    if v_threshold:
+        axis.vlines([v_threshold], *ylim, colors="C4", linestyles="dashed")
+
+    axis.set_ylim(*ylim)
+
+    axis.legend()
+
+    axis.set_title(title)
+    axis.set_xlabel(xlabel)
+    axis.set_ylabel(ylabel)
+
+    plt.savefig(str(path))
diff --git a/tests/integration/utils.py b/tests/integration/utils.py
new file mode 100644
index 00000000..cca2f1f8
--- /dev/null
+++ b/tests/integration/utils.py
@@ -0,0 +1,34 @@
+from pathlib import Path
+
+import numpy as np
+
+
+def abs_diff(x1, x2):
+    return np.abs(x1 - x2)
+
+
+def remove_file(path, missing_ok=True):
+    path = Path(path)
+    try:
+        path.unlink()
+    except FileNotFoundError:
+        if not missing_ok:
+            raise
+
+
+def input_dir() -> Path:
+    path = _this_dir().joinpath("input")
+    assert path.is_dir()  # sanity check
+    return path
+
+
+def output_dir() -> Path:
+    path = _this_dir().joinpath("output")
+    assert path.is_dir()  # sanity check
+    return path
+
+
+def _this_dir() -> Path:
+    path = Path(__file__).resolve().parent
+    assert path.is_dir()  # sanity check
+    return path